diff options
Diffstat (limited to 'trunk/etherpad/src/etherpad')
135 files changed, 29353 insertions, 0 deletions
diff --git a/trunk/etherpad/src/etherpad/admin/shell.js b/trunk/etherpad/src/etherpad/admin/shell.js new file mode 100644 index 0000000..391d524 --- /dev/null +++ b/trunk/etherpad/src/etherpad/admin/shell.js @@ -0,0 +1,127 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("funhtml.*"); +import("jsutils.cmp"); +import("jsutils.eachProperty"); +import("exceptionutils"); +import("execution"); +import("stringutils.trim"); + +import("etherpad.sessions.getSession"); +import("etherpad.utils.*"); + +function _splitCommand(cmd) { + var parts = [[], []]; + var importing = true; + cmd.split("\n").forEach(function(l) { + if ((trim(l).length > 0) && + (trim(l).indexOf("import") != 0)) { + importing = false; + } + + if (importing) { + parts[0].push(l); + } else { + parts[1].push(l); + } + }); + + parts[0] = parts[0].join("\n"); + parts[1] = parts[1].join("\n"); + return parts; +} + +function getResult(cmd) { + var resultString = (function() { + try { + var parts = _splitCommand(cmd); + result = execution.fancyAssEval(parts[0], parts[1]); + } catch (e) { + // if (e instanceof JavaException) { + // e = new net.appjet.bodylock.JSRuntimeException(e.getMessage(), e.javaException); + // } + if (appjet.config.devMode) { + (e.javaException || e.rhinoException || e).printStackTrace(); + } + result = exceptionutils.getStackTracePlain(e); + } + var resultString; + try { + resultString = ((result && result.toString) ? result.toString() : String(result)); + } catch (ex) { + resultString = "Error converting result to string: "+ex.toString(); + } + return resultString; + })(); + return resultString; +} + +function _renderCommandShell() { + // run command if necessary + if (request.params.cmd) { + var cmd = request.params.cmd; + var resultString = getResult(cmd); + + getSession().shellCommand = cmd; + getSession().shellResult = resultString; + response.redirect(request.path+(request.query?'?'+request.query:'')); + } + + var div = DIV({style: "padding: 4px; margin: 4px; background: #eee; " + + "border: 1px solid #338"}); + // command div + var oldCmd = getSession().shellCommand || ""; + var commandDiv = DIV({style: "width: 100%; margin: 4px 0;"}); + commandDiv.push(FORM({style: "width: 100%;", + method: "POST", action: request.path + (request.query?'?'+request.query:'')}, + TEXTAREA({name: "cmd", + style: "border: 1px solid #555;" + + "width: 100%; height: 160px; font-family: monospace;"}, + html(oldCmd)), + INPUT({type: "submit"}))); + + // result div + var resultDiv = DIV({style: ""}); + var isResult = getSession().shellResult != null; + if (isResult) { + resultDiv.push(DIV( + PRE({style: 'border: 1px solid #555; font-family: monospace; margin: 4px 0; padding: 4px;'}, + getSession().shellResult))); + delete getSession().shellResult; + resultDiv.push(DIV({style: "text-align: right;"}, + A({href: qpath({})}, "clear"))); + } else { + resultDiv.push(P("result will go here")); + } + + var t = TABLE({border: 0, cellspacing: 0, cellpadding: 0, width: "100%", + style: "width: 100%;"}); + t.push(TR(TH({width: "49%", align: "left"}, " Command:"), + TH({width: "49%", align: "left"}, " "+(isResult ? "Result:" : ""))), + TR(TD({valign: "top", style: 'padding: 4px;'}, commandDiv), + TD({valign: "top", style: 'padding: 4px;'}, resultDiv))); + div.push(t); + return div; +} + +function handleRequest() { + var body = BODY(); + body.push(A({href: '/ep/admin/'}, html("« Admin"))); + body.push(BR(), BR()); + body.push(_renderCommandShell()); + response.write(HTML(body)); +} diff --git a/trunk/etherpad/src/etherpad/billing/billing.js b/trunk/etherpad/src/etherpad/billing/billing.js new file mode 100644 index 0000000..444c233 --- /dev/null +++ b/trunk/etherpad/src/etherpad/billing/billing.js @@ -0,0 +1,800 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("dateutils.*"); +import("fastJSON"); +import("jsutils.eachProperty"); +import("netutils.urlPost"); +import("sqlbase.sqlbase"); +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); +import("stringutils.{md5,repeat}"); + +import("etherpad.log.{custom=>eplog}"); + + +jimport("java.lang.System.out.println"); + +function clearKeys(obj, keys) { + var newObj = {}; + eachProperty(obj, function(k, v) { + var isCopied = false; + keys.forEach(function(key) { + if (k == key.name && + key.valueTest(v)) { + newObj[k] = key.valueReplace(v); + isCopied = true; + } + }); + if (! isCopied) { + if (typeof(obj[k]) == 'object') { + newObj[k] = clearKeys(v, keys); + } else { + newObj[k] = v; + } + } + }); + return newObj; +} + +function replaceWithX(s) { + return repeat("X", s.length); +} + +function log(obj) { + eplog('billing', clearKeys(obj, [ + {name: "ACCT", + valueTest: function(s) { return /^\d{15,16}$/.test(s) }, + valueReplace: replaceWithX}, + {name: "CVV2", + valueTest: function(s) { return /^\d{3,4}$/.test(s) }, + valueReplace: replaceWithX}])); +} + +var _USER = function() { return appjet.config['etherpad.paypal.user'] || "zamfir_1239051855_biz_api1.gmail.com"; } +var _PWD = function() { return appjet.config['etherpad.paypal.pwd'] || "1239051867"; } +var _SIGNATURE = function() { return appjet.config['etherpad.paypal.signature'] || "AQU0e5vuZCvSg-XJploSa.sGUDlpAwAy5fz.FhtfOQ25Qa9sFLDt7Bmp"; } +var _RECEIVER = function() { return appjet.config['etherpad.paypal.receiver'] || "zamfir_1239051855_biz@gmail.com"; } +var _paypalApiUrl = function() { return appjet.config['etherpad.paypal.apiUrl'] || "https://api-3t.sandbox.paypal.com/nvp"; } +var _paypalWebUrl = function() { return appjet.config['etherpad.paypal.webUrl'] || "https://www.sandbox.paypal.com/cgi-bin/webscr"; } +function paypalPurchaseUrl(token) { + return (appjet.config['etherpad.paypal.purchaseUrl'] || "https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=")+token; +} + +function getPurchase(id) { + return sqlobj.selectSingle('billing_purchase', {id: id}); +} + +function getPurchaseForCustomer(customerId) { + return sqlobj.selectSingle('billing_purchase', {customer: customerId}); +} + +function updatePurchase(id, fields) { + sqlobj.updateSingle('billing_purchase', {id: id}, fields); +} + +function getInvoicesForPurchase(purchaseId) { + return sqlobj.selectMulti('billing_invoice', {purchase: purchaseId}); +} + +function getInvoice(id) { + return sqlobj.selectSingle('billing_invoice', {id: id}); +} + +function createInvoice() { + return _newInvoice(); +} + +function updateInvoice(id, fields) { + sqlobj.updateSingle('billing_invoice', {id: id}, fields) +} + +function getTransaction(id) { + return sqlobj.selectSingle('billing_transaction', {id: id}); +} +function getTransactionByExternalId(txnId) { + return sqlobj.selectSingle('billing_transaction', {txnId: txnId}); +} + +function getTransactionsForCustomer(customerId) { + return sqlobj.selectMulti('billing_transaction', {customer: customerId}); +} + +function getPendingTransactionsForCustomer(customerId) { + return sqlobj.selectMulti('billing_transaction', {customer: customerId, status: 'pending'}); +} + +function _updateTransaction(id, fields) { + return sqlobj.updateSingle('billing_transaction', {id: id}, fields); +} + +function getAdjustments(invoiceId) { + return sqlobj.selectMulti('billing_adjustment', {invoice: invoiceId}); +} + +function createSubscription(customer, product, dollars, couponCode) { + var purchaseId = _newPurchase(customer, product, dollarsToCents(dollars), couponCode); + _purchaseActive(purchaseId); + updatePurchase(purchaseId, {type: 'subscription', paidThrough: nextMonth(noon(new Date))}); + return purchaseId; +} + +function _newPurchase(customer, product, cents, couponCode) { + var purchaseId = sqlobj.insert('billing_purchase', { + customer: customer, + product: product, + cost: cents, + coupon: couponCode, + status: 'inactive' + }); + return purchaseId; +} + +function _newInvoice() { + var invoiceId = sqlobj.insert('billing_invoice', { + time: new Date(), + purchase: -1, + amt: 0, + status: 'pending' + }); + return invoiceId; +} + +function _newTransaction(customer, cents) { + var transactionId = sqlobj.insert('billing_transaction', { + customer: customer, + time: new Date(), + amt: cents, + status: 'new' + }); + return transactionId; +} + +function _newAdjustment(transaction, invoice, cents) { + sqlobj.insert('billing_adjustment', { + transaction: transaction, + invoice: invoice, + time: new Date(), + amt: cents + }); +} + +function _transactionSuccess(transaction, txnId, payInfo) { + _updateTransaction(transaction, { + status: 'success', txnId: txnId, time: new Date(), payInfo: payInfo + }); +} + +function _transactionFailure(transaction, txnId) { + _updateTransaction(transaction, { + status: 'failure', txnId: txnId, time: new Date() + }); +} + +function _transactionPending(transaction, txnId) { + _updateTransaction(transaction, { + status: 'pending', txnId: txnId, time: new Date() + }); +} + +function _invoicePaid(invoice) { + updateInvoice(invoice, {status: 'paid'}); +} + +function _purchaseActive(purchase) { + updatePurchase(purchase, {status: 'active'}); +} + +function _purchaseExtend(purchase, monthCount) { + var expiration = getPurchase(purchase).paidThrough; + for (var i = monthCount; i > 0; i--) { + expiration = nextMonth(expiration); + } + // paying your invoice always makes you current. + if (expiration < new Date) { + expiration = nextMonth(new Date); + } + updatePurchase(purchase, {paidThrough: expiration}); +} + +function _doPost(url, body) { + try { + var ret = urlPost(url, body); + } catch (e) { + if (e.javaException) { + net.appjet.oui.exceptionlog.apply(e.javaException); + } + return { error: e }; + } + return { value: ret }; +} + +function _doPaypalNvpPost(properties0) { + return { + status: 'failure', + errorMessage: "Billing has been discontinued. No new services may be purchased." + } + // var properties = { + // USER: _USER(), + // PWD: _PWD(), + // SIGNATURE: _SIGNATURE(), + // VERSION: "56.0" + // } + // eachProperty(properties0, function(k, v) { + // if (v !== undefined) { + // properties[k] = v; + // } + // }) + // log({'type': 'api call', 'value': properties}); + // var ret = _doPost(_paypalApiUrl(), properties); + // if (ret.error) { + // return { + // status: 'failure', + // exception: ret.error.javaException || ret.error, + // errorMessage: ret.error.message + // } + // } + // ret = ret.value; + // var paypalResponse = {}; + // ret.content.split("&").forEach(function(x) { + // var parts = x.split("="); + // paypalResponse[decodeURIComponent(parts[0])] = + // decodeURIComponent(parts[1]); + // }) + // + // var res = paypalResponse; + // log(res) + // if (res.ACK == "Success" || res.ACK == "SuccessWithWarning") { + // return { + // status: 'success', + // response: res + // } + // } else { + // errors = []; + // for (var i = 0; res['L_LONGMESSAGE'+i]; ++i) { + // errors.push(res['L_LONGMESSAGE'+i]); + // } + // return { + // status: 'failure', + // errorMessage: errors.join(", "), + // errorMessages: errors, + // response: res + // } + // } +} + +// status -> 'completion', 'bad', 'redundant', 'possible_fraud' +function handlePaypalNotification() { + var content = (typeof(request.content) == 'string' ? request.content : undefined); + if (! content) { + return new BillingResult('bad', "no content"); + } + log({'type': 'paypal-notification', 'content': content}); + var params = {}; + content.split("&").forEach(function(x) { + var parts = x.split("="); + params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1]); + }); + var txnId = params.txn_id; + var properties = []; + for(var i in params) { + properties.push(i+" -> "+params[i]); + } + var debugString = properties.join(", "); + log({'type': 'parsed-paypal-notification', 'value': debugString}); + var transaction = getTransactionByExternalId(txnId); + log({'type': 'notification-transaction', 'value': (transaction || {})}); + if (_RECEIVER() != params.receiver_email) { + return new BillingResult('possible_fraud', debugString); + } + if (params.payment_status == "Completed" && transaction && + (transaction.status == 'pending' || transaction.status == 'new')) { + var ret = _doPost(_paypalWebUrl(), "cmd=_notify-validate&"+content); + if (ret.error || ret.value.content != "VERIFIED") { + return new BillingResult('possible_fraud', debugString); + } + var invoice = getInvoice(params.invoice); + if (invoice.amt != dollarsToCents(params.mc_gross)) { + return new BillingResult('possible_fraud', debugString); + } + + sqlcommon.inTransaction(function () { + _transactionSuccess(transaction.id, txnId, "via eCheck"); + _invoicePaid(invoice.id); + _purchaseActive(invoice.purchase); + }); + var purchase = getPurchase(invoice.purchase); + return new BillingResult('completion', debugString, null, + new PurchaseInfo(params.custom, + invoice.id, + transaction.id, + params.txn_id, + purchase.id, + centsToDollars(invoice.amt), + purchase.couponCode, + purchase.time, + undefined)); + } else { + return new BillingResult('redundant', debugString); + } +} + +function _expressCheckoutCustom(invoiceId, transactionId) { + return md5("zimki_sucks"+invoiceId+transactionId); +} + +function PurchaseInfo(custom, invoiceId, transactionId, paypalId, purchaseId, dollars, couponCode, time, token, description) { + this.__defineGetter__("custom", function() { return custom }); + this.__defineGetter__("invoiceId", function() { return invoiceId }); + this.__defineGetter__("transactionId", function() { return transactionId }); + this.__defineGetter__("paypalId", function() { return paypalId }); + this.__defineGetter__("purchaseId", function() { return purchaseId }); + this.__defineGetter__("cost", function() { return dollars }); + this.__defineGetter__("couponCode", function() { return couponCode }); + this.__defineGetter__("time", function() { return time }); + this.__defineGetter__("token", function() { return token }); + this.__defineGetter__("description", function() { return description }); +} + +function PayerInfo(paypalResult) { + this.__defineGetter__("payerId", function() { return paypalResult.response.PAYERID }); + this.__defineGetter__("email", function() { return paypalResult.response.EMAIL }); + this.__defineGetter__("businessName", function() { return paypalResult.response.BUSINESS }); + this.__defineGetter__("nameSalutation", function() { return paypalResult.response.SALUTATION }); + this.__defineGetter__("nameFirst", function() { return paypalResult.response.FIRSTNAME }); + this.__defineGetter__("nameMiddle", function() { return paypalResult.response.MIDDLENAME }); + this.__defineGetter__("nameLast", function() { return paypalResult.response.LASTNAME }); +} + +function BillingResult(status, debug, errorField, purchaseInfo, payerInfo) { + this.__defineGetter__("status", function() { return status }); + this.__defineGetter__("debug", function() { return debug }); + this.__defineGetter__("errorField", function() { return errorField }); + this.__defineGetter__("purchaseInfo", function() { return purchaseInfo }); + this.__defineGetter__("payerInfo", function() { return payerInfo }); +} + +function dollarsToCents(dollars) { + return Math.round(Number(dollars)*100); +} + +function centsToDollars(cents) { + return Math.round(Number(cents)) / 100; +} + +function verifyDollars(dollars) { + return Math.round(Number(dollars)*100)/100; +} + +function beginExpressPurchase(invoiceId, customerId, productId, dollars, couponCode, successUrl, failureUrl, notifyUrl, authorizeOnly) { + var cents = dollarsToCents(dollars); + var time = new Date(); + var purchaseId; + var transactionid; + if (! authorizeOnly) { + try { + sqlcommon.inTransaction(function() { + purchaseId = _newPurchase(customerId, productId, cents, couponCode); + updateInvoice(invoiceId, {purchase: purchaseId, amt: cents}); + transactionId = _newTransaction(customerId, cents); + _newAdjustment(transactionId, invoiceId, cents); + }); + } catch (e) { + if (e instanceof BillingResult) { return e; } + throw e; + } + } + + var paypalResult = + _setExpressCheckout(invoiceId, transactionId, cents, + successUrl, failureUrl, notifyUrl, authorizeOnly); + + if (paypalResult.status == 'success') { + var token = paypalResult.response.TOKEN; + return new BillingResult('success', paypalResult, null, new PurchaseInfo( + _expressCheckoutCustom(invoiceId, transactionId), + invoiceId, + transactionId, + undefined, + purchaseId, + verifyDollars(dollars), + couponCode, + time, + token)); + } else { + return new BillingResult('failure', paypalResult); + } +} + +function _setExpressCheckout(invoiceId, transactionId, cents, successUrl, failureUrl, notifyUrl, authorizeOnly) { + var properties = { + INVNUM: invoiceId, + + METHOD: 'SetExpressCheckout', + CUSTOM: + _expressCheckoutCustom(invoiceId, transactionId), + MAXAMT: centsToDollars(cents), + RETURNURL: successUrl, + CANCELURL: failureUrl, + NOTIFYURL: notifyUrl, + NOSHIPPING: 1, + PAYMENTACTION: (authorizeOnly ? 'Authorization' : 'Sale'), + + AMT: centsToDollars(cents) + } + + return _doPaypalNvpPost(properties); +} + +function continueExpressPurchase(purchaseInfo, authorizeOnly) { + var paypalResult = _getExpressCheckoutDetails(purchaseInfo.token, authorizeOnly) + if (paypalResult.status == 'success') { + if (! authorizeOnly) { + if (paypalResult.response.INVNUM != purchaseInfo.invoiceId) { + return new BillingResult('failure', "invoice id mismatch"); + } + } + if (paypalResult.response.CUSTOM != + _expressCheckoutCustom(purchaseInfo.invoiceId, purchaseInfo.transactionId)) { + return new BillingResult('failure', "custom mismatch"); + } + return new BillingResult('success', paypalResult, null, null, new PayerInfo(paypalResult)); + } else { + return new BillingResult('failure', paypalResult); + } +} + +function _getExpressCheckoutDetails(token, authorizeOnly) { + var properties = { + METHOD: 'GetExpresscheckoutDetails', + TOKEN: token, + } + + return _doPaypalNvpPost(properties); +} + +function completeExpressPurchase(purchaseInfo, payerInfo, notifyUrl, authorizeOnly) { + var paypalResult = _doExpressCheckoutPayment(purchaseInfo, payerInfo, notifyUrl, authorizeOnly); + + if (paypalResult.status == 'success') { + if (paypalResult.response.PAYMENTSTATUS == 'Completed') { + if (! authorizeOnly) { + sqlcommon.inTransaction(function() { + _transactionSuccess(purchaseInfo.transactionId, + paypalResult.response.TRANSACTIONID, "via PayPal"); + _invoicePaid(purchaseInfo.invoiceId); + _purchaseActive(purchaseInfo.purchaseId); + }); + } + return new BillingResult('success', paypalResult); + } else if (paypalResult.response.PAYMENTSTATUS == 'Pending') { + if (! authorizeOnly) { + sqlcommon.inTransaction(function() { + _transactionPending(purchaseInfo.transactionId, + paypalResult.response.TRANSACTIONID); + }); + } + return new BillingResult('pending', paypalResult); + } + } else { + if (! authorizeOnly) { + sqlcommon.inTransaction(function() { + _transactionFailure(purchaseInfo.transactionId, + (paypalResult.response ? + paypalResult.response.TRANSACTIONID || "" : + "")); + }); + } + return new BillingResult('failure', paypalResult); + } +} + +function _doExpressCheckoutPayment(purchaseInfo, payerInfo, notifyUrl, authorizeOnly) { + var properties = { + METHOD: 'DoExpressCheckoutPayment', + TOKEN: purchaseInfo.token, + PAYMENTACTION: (authorizeOnly ? 'Authorization' : 'Sale'), + + NOTIFYURL: notifyUrl, + + PAYERID: payerInfo.payerId, + + AMT: verifyDollars(purchaseInfo.cost), // dollars + INVNUM: purchaseInfo.invoiceId, + CUSTOM: + _expressCheckoutCustom(purchaseInfo.invoiceId, purchaseInfo.transactionId) + } + + return _doPaypalNvpPost(properties); +} + +// which field has error? and, is it not user-correctable? +var _directErrorCodes = { + '10502': ['cardExpiration'], + '10504': ['cardCvv'], + '10505': ['addressStreet', true], + '10508': ['cardExpiration'], + '10510': ['cardType'], + '10512': ['nameFirst'], + '10513': ['nameLast'], + '10519': ['cardNumber'], + '10521': ['cardNumber'], + '10527': ['cardNumber'], + '10534': ['cardNumber', true], + '10535': ['cardNumber'], + '10536': ['invoiceId', true], + '10537': ['addressCountry', true], + '10540': ['addressStreet', true], + '10541': ['cardNumber', true], + '10554': ['address', true], + '10555': ['address', true], + '10556': ['address', true], + '10561': ['address'], + '10562': ['cardExpiration'], + '10563': ['cardExpiration'], + '10565': ['addressCountry'], + '10566': ['cardType'], + '10571': ['cardCvv'], + '10701': ['address'], + '10702': ['addressStreet'], + '10703': ['addressStreet2'], + '10704': ['addressCity'], + '10705': ['addressState'], + '10706': ['addressZip'], + '10707': ['addressCountry'], + '10708': ['address'], + '10709': ['addressStreet'], + '10710': ['addressCity'], + '10711': ['addressState'], + '10712': ['addressZip'], + '10713': ['addressCountry'], + '10714': ['address'], + '10715': ['addressState'], + '10716': ['addressZip'], + '10717': ['addressZip'], + '10718': ['addressCity,addressState'], + '10748': ['cardCvv'], + '10752': ['card'], + '10756': ['address,card'], + '10759': ['cardNumber'], + '10762': ['cardCvv'], + '11611': function(response) { + var avsCode = response.AVSCODE; + var cvv2Match = response.CVV2MATCH; + var errorFields = []; + switch (avsCode) { + case 'N': case 'C': case 'A': case 'B': + case 'R': case 'S': case 'U': case 'G': + case 'I': case 'E': + errorFields.push('address'); + } + switch (cvv2Match) { + case 'N': + errorFields.push('cardCvv'); + } + return [errorFields.join(",")]; + }, + '15004': ['cardCvv'], + '15005': ['cardNumber'], + '15006': ['cardNumber'], + '15007': ['cardNumber'] +} + +function authorizePurchase(payinfo, notifyUrl) { + return directPurchase(undefined, undefined, undefined, 1, undefined, payinfo, notifyUrl, true); +} + +function directPurchase(invoiceId, customerId, productId, dollars, couponCode, payinfo, notifyUrl, authorizeOnly) { + var time = new Date(); + var cents = dollarsToCents(dollars); + + var purchaseId, transactionId; + + if (! authorizeOnly) { + try { + sqlcommon.inTransaction(function() { + purchaseId = _newPurchase(customerId, productId, cents, couponCode); + updateInvoice(invoiceId, {purchase: purchaseId, amt: cents}); + transactionId = _newTransaction(customerId, cents); + _newAdjustment(transactionId, invoiceId, cents); + }); + } catch (e) { + if (e instanceof BillingResult) { return e; } + if (e.javaException || e.rhinoException) { + throw e.javaException || e.rhinoException; + } + throw e; + } + } + + var paypalResult = _doDirectPurchase(invoiceId, cents, payinfo, notifyUrl, authorizeOnly); + + if (paypalResult.status == 'success') { + if (! authorizeOnly) { + sqlcommon.inTransaction(function() { + _transactionSuccess(transactionId, + paypalResult.response.TRANSACTIONID, + payinfo.cardType+" ending in "+payinfo.cardNumber.substr(-4)); + _invoicePaid(invoiceId); + _purchaseActive(purchaseId); + }); + } + return new BillingResult('success', paypalResult, null, new PurchaseInfo( + undefined, + invoiceId, + transactionId, + paypalResult.response.TRANSACTIONID, + purchaseId, + verifyDollars(dollars), + couponCode, + time, + undefined)); + } else { + if (! authorizeOnly) { + sqlcommon.inTransaction(function() { + _transactionFailure(transactionId, + (paypalResult.response ? + paypalResult.response.TRANSACTIONID || "": + "")); + }); + } + return new BillingResult('failure', paypalResult, _getErrorCodes(paypalResult.response)); + } +} + +function _getErrorCodes(paypalResponse) { + var errorCodes = {userErrors: [], permanentErrors: []}; + if (! paypalResponse) { + return undefined; + } + for (var i = 0; paypalResponse['L_ERRORCODE'+i]; ++i) { + var code = paypalResponse['L_ERRORCODE'+i]; + var errorField = _directErrorCodes[code]; + if (typeof(errorField) == 'function') { + errorField = errorField(paypalResponse); + } + if (errorField && errorField[1]) { + Array.prototype.push.apply(errorCodes.permanentErrors, errorField[0].split(",")); + } else if (errorField) { + Array.prototype.push.apply(errorCodes.userErrors, errorField[0].split(",")); + } + } + return errorCodes; +} + +function _doDirectPurchase(invoiceId, cents, payinfo, notifyUrl, authorizeOnly) { + var properties = { + INVNUM: invoiceId, + + METHOD: 'DoDirectPayment', + PAYMENTACTION: (authorizeOnly ? 'Authorization' : 'Sale'), + IPADDRESS: request.clientAddr, + NOTIFYURL: notifyUrl, + + CREDITCARDTYPE: payinfo.cardType, + ACCT: payinfo.cardNumber, + EXPDATE: payinfo.cardExpiration, + CVV2: payinfo.cardCvv, + + SALUTATION: payinfo.nameSalutation, + FIRSTNAME: payinfo.nameFirst, + MIDDLENAME: payinfo.nameMiddle, + LASTNAME: payinfo.nameLast, + SUFFIX: payinfo.nameSuffix, + + STREET: payinfo.addressStreet, + STREET2: payinfo.addressStreet2, + CITY: payinfo.addressCity, + STATE: payinfo.addressState, + COUNTRYCODE: payinfo.addressCountry, + ZIP: payinfo.addressZip, + + AMT: centsToDollars(cents) + } + + return _doPaypalNvpPost(properties); +} + +// function directAuthorization(payInfo, dollars, notifyUrl) { +// var paypalResult = _doDirectPurchase(undefined, dollarsToCents(dollars), payInfo, notifyUrl, true); +// if (paypalResult.status == 'success') { +// return new BillingResult('success', paypalResult, null, new PurchaseInfo( +// undefined, +// undefined, +// paypalResult.response.TRANSACTIONID, +// undefined, +// verifyDollars(dollars), +// undefined, +// undefined, +// undefined)); +// } else { +// return new BillingResult('failure', paypalResult, _getErrorCodes(paypalResult.response)); +// } +// } + +function asyncRecurringPurchase(invoiceId, purchaseId, oldTransactionId, paymentInfo, dollars, monthCount, notifyUrl) { + var time = new Date(); + var cents = dollarsToCents(dollars); + + var purchase, transactionId; + + try { + sqlcommon.inTransaction(function() { + // purchaseId = _newPurchase(customerId, productId, cents, couponCode); + purchase = getPurchase(purchaseId); + updateInvoice(invoiceId, {purchase: purchaseId, amt: cents}); + transactionId = _newTransaction(purchase.customer, cents); + _newAdjustment(transactionId, invoiceId, cents); + }); + } catch (e) { + if (e instanceof BillingResult) { return e; } + if (e.rhinoException) { + throw e.rhinoException; + } + throw e; + } + + // do transaction using previous transaction as template + var paypalResult; + if (cents == 0) { + // can't actually charge nothing, so fake it. + paypalResult = { status: 'success', response: { TRANSACTIONID: null }} + } else { + paypalResult = _doReferenceTransaction(invoiceId, cents, oldTransactionId, notifyUrl); + } + + if (paypalResult.status == 'success') { + sqlcommon.inTransaction(function() { + _transactionSuccess(transactionId, + paypalResult.response.TRANSACTIONID, + paymentInfo); + _invoicePaid(invoiceId); + _purchaseActive(purchaseId); + _purchaseExtend(purchaseId, monthCount); + }); + return new BillingResult('success', paypalResult, null, new PurchaseInfo( + undefined, + invoiceId, + transactionId, + paypalResult.response.TRANSACTIONID, + purchaseId, + verifyDollars(dollars), + undefined, + time, + undefined)); + } else { + sqlcommon.inTransaction(function() { + _transactionFailure(transactionId, + (paypalResult.response ? + paypalResult.response.TRANSACTIONID || "": + "")); + }); + return new BillingResult('failure', paypalResult, _getErrorCodes(paypalResult.response)); + } +} + +function _doReferenceTransaction(invoiceId, cents, transactionId, notifyUrl) { + var properties = { + METHOD: 'DoReferenceTransaction', + PAYMENTACTION: 'Sale', + + REFERENCEID: transactionId, + AMT: centsToDollars(cents), + INVNUM: invoiceId + } + + return _doPaypalNvpPost(properties); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/billing/fields.js b/trunk/etherpad/src/etherpad/billing/fields.js new file mode 100644 index 0000000..4a307ac --- /dev/null +++ b/trunk/etherpad/src/etherpad/billing/fields.js @@ -0,0 +1,219 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +// Taken from paypal's form +var countryList = [ + ["US", "United States"], + ["AL", "Albania"], + ["DZ", "Algeria"], + ["AD", "Andorra"], + ["AO", "Angola"], + ["AI", "Anguilla"], + ["AG", "Antigua and Barbuda"], + ["AR", "Argentina"], + ["AM", "Armenia"], + ["AW", "Aruba"], + ["AU", "Australia"], + ["AT", "Austria"], + ["AZ", "Azerbaijan Republic"], + ["BS", "Bahamas"], + ["BH", "Bahrain"], + ["BB", "Barbados"], + ["BE", "Belgium"], + ["BZ", "Belize"], + ["BJ", "Benin"], + ["BM", "Bermuda"], + ["BT", "Bhutan"], + ["BO", "Bolivia"], + ["BA", "Bosnia and Herzegovina"], + ["BW", "Botswana"], + ["BR", "Brazil"], + ["VG", "British Virgin Islands"], + ["BN", "Brunei"], + ["BG", "Bulgaria"], + ["BF", "Burkina Faso"], + ["BI", "Burundi"], + ["KH", "Cambodia"], + ["CA", "Canada"], + ["CV", "Cape Verde"], + ["KY", "Cayman Islands"], + ["TD", "Chad"], + ["CL", "Chile"], + ["C2", "China"], + ["CO", "Colombia"], + ["KM", "Comoros"], + ["CK", "Cook Islands"], + ["CR", "Costa Rica"], + ["HR", "Croatia"], + ["CY", "Cyprus"], + ["CZ", "Czech Republic"], + ["CD", "Democratic Republic of the Congo"], + ["DK", "Denmark"], + ["DJ", "Djibouti"], + ["DM", "Dominica"], + ["DO", "Dominican Republic"], + ["EC", "Ecuador"], + ["SV", "El Salvador"], + ["ER", "Eritrea"], + ["EE", "Estonia"], + ["ET", "Ethiopia"], + ["FK", "Falkland Islands"], + ["FO", "Faroe Islands"], + ["FM", "Federated States of Micronesia"], + ["FJ", "Fiji"], + ["FI", "Finland"], + ["FR", "France"], + ["GF", "French Guiana"], + ["PF", "French Polynesia"], + ["GA", "Gabon Republic"], + ["GM", "Gambia"], + ["DE", "Germany"], + ["GI", "Gibraltar"], + ["GR", "Greece"], + ["GL", "Greenland"], + ["GD", "Grenada"], + ["GP", "Guadeloupe"], + ["GT", "Guatemala"], + ["GN", "Guinea"], + ["GW", "Guinea Bissau"], + ["GY", "Guyana"], + ["HN", "Honduras"], + ["HK", "Hong Kong"], + ["HU", "Hungary"], + ["IS", "Iceland"], + ["IN", "India"], + ["ID", "Indonesia"], + ["IE", "Ireland"], + ["IL", "Israel"], + ["IT", "Italy"], + ["JM", "Jamaica"], + ["JP", "Japan"], + ["JO", "Jordan"], + ["KZ", "Kazakhstan"], + ["KE", "Kenya"], + ["KI", "Kiribati"], + ["KW", "Kuwait"], + ["KG", "Kyrgyzstan"], + ["LA", "Laos"], + ["LV", "Latvia"], + ["LS", "Lesotho"], + ["LI", "Liechtenstein"], + ["LT", "Lithuania"], + ["LU", "Luxembourg"], + ["MG", "Madagascar"], + ["MW", "Malawi"], + ["MY", "Malaysia"], + ["MV", "Maldives"], + ["ML", "Mali"], + ["MT", "Malta"], + ["MH", "Marshall Islands"], + ["MQ", "Martinique"], + ["MR", "Mauritania"], + ["MU", "Mauritius"], + ["YT", "Mayotte"], + ["MX", "Mexico"], + ["MN", "Mongolia"], + ["MS", "Montserrat"], + ["MA", "Morocco"], + ["MZ", "Mozambique"], + ["NA", "Namibia"], + ["NR", "Nauru"], + ["NP", "Nepal"], + ["NL", "Netherlands"], + ["AN", "Netherlands Antilles"], + ["NC", "New Caledonia"], + ["NZ", "New Zealand"], + ["NI", "Nicaragua"], + ["NE", "Niger"], + ["NU", "Niue"], + ["NF", "Norfolk Island"], + ["NO", "Norway"], + ["OM", "Oman"], + ["PW", "Palau"], + ["PA", "Panama"], + ["PG", "Papua New Guinea"], + ["PE", "Peru"], + ["PN", "Pitcairn Islands"], + ["PL", "Poland"], + ["PT", "Portugal"], + ["QA", "Qatar"], + ["CG", "Republic of the Congo"], + ["RE", "Reunion"], + ["RO", "Romania"], + ["RU", "Russia"], + ["VC", "Saint Vincent and the Grenadines"], + ["WS", "Samoa"], + ["SM", "San Marino"], + ["ST", "São Tomé and PrÃncipe"], + ["SA", "Saudi Arabia"], + ["SN", "Senegal"], + ["SC", "Seychelles"], + ["SL", "Sierra Leone"], + ["SG", "Singapore"], + ["SK", "Slovakia"], + ["SI", "Slovenia"], + ["SB", "Solomon Islands"], + ["SO", "Somalia"], + ["ZA", "South Africa"], + ["KR", "South Korea"], + ["ES", "Spain"], + ["LK", "Sri Lanka"], + ["SH", "St. Helena"], + ["KN", "St. Kitts and Nevis"], + ["LC", "St. Lucia"], + ["PM", "St. Pierre and Miquelon"], + ["SR", "Suriname"], + ["SJ", "Svalbard and Jan Mayen Islands"], + ["SZ", "Swaziland"], + ["SE", "Sweden"], + ["CH", "Switzerland"], + ["TW", "Taiwan"], + ["TJ", "Tajikistan"], + ["TZ", "Tanzania"], + ["TH", "Thailand"], + ["TG", "Togo"], + ["TO", "Tonga"], + ["TT", "Trinidad and Tobago"], + ["TN", "Tunisia"], + ["TR", "Turkey"], + ["TM", "Turkmenistan"], + ["TC", "Turks and Caicos Islands"], + ["TV", "Tuvalu"], + ["UG", "Uganda"], + ["UA", "Ukraine"], + ["AE", "United Arab Emirates"], + ["GB", "United Kingdom"], + ["UY", "Uruguay"], + ["VU", "Vanuatu"], + ["VA", "Vatican City State"], + ["VE", "Venezuela"], + ["VN", "Vietnam"], + ["WF", "Wallis and Futuna Islands"], + ["YE", "Yemen"], + ["ZM", "Zambia"], +]; + +var usaStateList = [ + "", "AK", "AL", "AR", "AZ", "CA", "CO", "CT", "DC", "DE", + "FL", "GA", "HI", "IA", "ID", "IL", "IN", "KS", "KY", "LA", + "MA", "MD", "ME", "MI", "MN", "MO", "MS", "MT", "NC", "ND", + "NE", "NH", "NJ", "NM", "NV", "NY", "OH", "OK", "OR", "PA", + "RI", "SC", "SD", "TN", "TX", "UT", "VA", "VT", "WA", "WI", + "WV", "WY", "AA", "AE", "AP", "AS", "FM", "GU", "MH", "MP", + "PR", "PW", "VI" +]; + diff --git a/trunk/etherpad/src/etherpad/billing/team_billing.js b/trunk/etherpad/src/etherpad/billing/team_billing.js new file mode 100644 index 0000000..ae8ae8a --- /dev/null +++ b/trunk/etherpad/src/etherpad/billing/team_billing.js @@ -0,0 +1,422 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("execution"); +import("exceptionutils"); +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon.inTransaction"); + +import("etherpad.billing.billing"); +import("etherpad.globals"); +import("etherpad.log"); +import("etherpad.pro.domains"); +import("etherpad.pro.pro_quotas"); +import("etherpad.store.checkout"); +import("etherpad.utils.renderTemplateAsString"); + +jimport("java.lang.System.out.println"); + +function recurringBillingNotifyUrl() { + return ""; +} + +function _billing() { + if (! appjet.cache.billing) { + appjet.cache.billing = {}; + } + return appjet.cache.billing; +} + +function _lpad(str, width, padDigit) { + str = String(str); + padDigit = (padDigit === undefined ? ' ' : padDigit); + var count = width - str.length; + var prepend = [] + for (var i = 0; i < count; ++i) { + prepend.push(padDigit); + } + return prepend.join("")+str; +} + +// utility functions + +function _dayToDateTime(date) { + return [date.getFullYear(), _lpad(date.getMonth()+1, 2, '0'), _lpad(date.getDate(), 2, '0')].join("-"); +} + +function _createInvoice(subscription) { + var maxUsers = getMaxUsers(subscription.customer); + var invoice = inTransaction(function() { + var invoiceId = billing.createInvoice(); + billing.updateInvoice( + invoiceId, + {purchase: subscription.id, + amt: billing.dollarsToCents(calculateSubscriptionCost(maxUsers, subscription.coupon)), + users: maxUsers}); + return billing.getInvoice(invoiceId); + }); + if (invoice) { + resetMaxUsers(subscription.customer) + } + return invoice; +} + +function getExpiredSubscriptions(date) { + return sqlobj.selectMulti('billing_purchase', + {type: 'subscription', + status: 'active', + paidThrough: ['<', _dayToDateTime(date)]}); +} + +function getAllSubscriptions() { + return sqlobj.selectMulti('billing_purchase', {type: 'subscription', status: 'active'}); +} + +function getSubscriptionForCustomer(customerId) { + return sqlobj.selectSingle('billing_purchase', + {type: 'subscription', + customer: customerId}); +} + +function getOrCreateInvoice(subscription) { + return inTransaction(function() { + var existingInvoice = + sqlobj.selectSingle('billing_invoice', + {purchase: subscription.id, status: 'pending'}); + if (existingInvoice) { + return existingInvoice; + } else { + return _createInvoice(subscription); + } + }); +} + +function getLatestPendingInvoice(subscriptionId) { + return sqlobj.selectMulti('billing_invoice', + {purchase: subscriptionId, status: 'pending'}, + {orderBy: '-time', limit: 1})[0]; +} + +function getLatestPaidInvoice(subscriptionId) { + return sqlobj.selectMulti('billing_invoice', + {purchase: subscriptionId, status: 'paid'}, + {orderBy: '-time', limit: 1})[0]; +} + +function pendingTransactions(customer) { + return billing.getPendingTransactionsForCustomer(customer); +} + +function checkPendingTransactions(transactions) { + // XXX: do nothing for now. + return transactions.length > 0; +} + +function getRecurringBillingTransactionId(customerId) { + return sqlobj.selectSingle('billing_payment_info', {customer: customerId}).transaction; +} + +function getRecurringBillingInfo(customerId) { + return sqlobj.selectSingle('billing_payment_info', {customer: customerId}); +} + +function clearRecurringBillingInfo(customerId) { + return sqlobj.deleteRows('billing_payment_info', {customer: customerId}); +} + +function setRecurringBillingInfo(customerId, fullName, email, paymentSummary, expiration, transactionId) { + var info = { + fullname: fullName, + email: email, + paymentsummary: paymentSummary, + expiration: expiration, + transaction: transactionId + } + inTransaction(function() { + if (sqlobj.selectSingle('billing_payment_info', {customer: customerId})) { + sqlobj.update('billing_payment_info', {customer: customerId}, info); + } else { + info.customer = customerId; + sqlobj.insert('billing_payment_info', info); + } + }); +} + +function createSubscription(customerId, couponCode) { + domainCacheClear(customerId); + return inTransaction(function() { + return billing.createSubscription(customerId, 'ONDEMAND', 0, couponCode); + }); +} + +function updateSubscriptionCouponCode(subscriptionId, couponCode) { + billing.updatePurchase(subscriptionId, {coupon: couponCode || ""}); +} + +function subscriptionChargeFailure(subscription, invoice, failureMessage) { + billing.updatePurchase(subscription.id, + {error: failureMessage, status: 'inactive'}); + sendFailureEmail(subscription, invoice); +} + +function subscriptionChargeSuccess(subscription, invoice) { + sendReceiptEmail(subscription, invoice); +} + +function errorFieldsToMessage(errorCodes) { + var prefix = "Your payment information was rejected. Please verify your "; + var errorList = (errorCodes.permanentErrors ? errorCodes.permanentErrors : errorCodes.userErrors); + + return prefix + + errorList.map(function(field) { + return checkout.billingCartFieldMap[field].d; + }).join(", ")+ + "." +} + +function getAllInvoices(customer) { + var purchase = getSubscriptionForCustomer(customer); + if (! purchase) { + return []; + } + return billing.getInvoicesForPurchase(purchase.id); +} + +// scheduled charges + +function attemptCharge(invoice, subscription) { + var billingInfo = getRecurringBillingInfo(subscription.customer); + if (! billingInfo) { + subscriptionChargeFailure(subscription, invoice, "No billing information on file."); + return false; + } + + var result = + billing.asyncRecurringPurchase( + invoice.id, + subscription.id, + billingInfo.transaction, + billingInfo.paymentsummary, + billing.centsToDollars(invoice.amt), + 1, // 1 month only for now + recurringBillingNotifyUrl); + if (result.status == 'success') { + subscriptionChargeSuccess(subscription, invoice); + return true; + } else { + subscriptionChargeFailure(subscription, invoice, errorFieldsToMessage(result.errorField)); + return false; + } +} + +function processSubscription(subscription) { + try { + var hasPendingTransactions = inTransaction(function() { + var transactions = pendingTransactions(subscription.customer); + if (checkPendingTransactions(transactions)) { + billing.log({type: 'pending-transactions-delay', subscription: subscription, transactions: transactions}); + // there are actual pending transactions. wait until tomorrow. + return true; + } else { + return false; + } + }); + if (hasPendingTransactions) { + return; + } + var invoice = getOrCreateInvoice(subscription); + + return attemptCharge(invoice, subscription); + } catch (e) { + log.logException(e); + billing.log({message: "Thrown error", + exception: exceptionutils.getStackTracePlain(e), + subscription: subscription}); + subscriptionChargeFailure(subscription, "Permanent failure. Please confirm your billing information."); + } finally { + domainCacheClear(subscription.customer); + } +} + +function processAllSubscriptions() { + var subs = getExpiredSubscriptions(new Date); + println("processing "+subs.length+" subscriptions."); + subs.forEach(processSubscription); +} + +function _scheduleNextDailyUpdate() { + // Run at 2:22am every day + var now = +(new Date); + var tomorrow = new Date(now + 1000*60*60*24); + tomorrow.setHours(2); + tomorrow.setMinutes(22); + tomorrow.setMilliseconds(222); + log.info("Scheduling next daily billing update for: "+tomorrow.toString()); + var delay = +tomorrow - (+(new Date)); + execution.scheduleTask('billing', "billingDailyUpdate", delay, []); +} + +serverhandlers.tasks.billingDailyUpdate = function() { + return; // do nothing, there's no more billing. + // if (! globals.isProduction()) { return; } + // try { + // processAllSubscriptions(); + // } finally { + // _scheduleNextDailyUpdate(); + // } +} + +function onStartup() { + execution.initTaskThreadPool("billing", 1); + _scheduleNextDailyUpdate(); +} + +// pricing + +function getMaxUsers(customer) { + return pro_quotas.getAccountUsageCount(customer); +} + +function resetMaxUsers(customer) { + pro_quotas.resetAccountUsageCount(customer); +} + +var COST_PER_USER = 8; + +function getCouponValue(couponCode) { + if (couponCode && couponCode.length == 8) { + return sqlobj.selectSingle('checkout_pro_referral', {id: couponCode}); + } +} + +function calculateSubscriptionCost(users, couponId) { + if (users <= globals.PRO_FREE_ACCOUNTS) { + return 0; + } + var coupon = getCouponValue(couponId); + var pctDiscount = (coupon ? coupon.pctDiscount : 0); + var freeUsers = (coupon ? coupon.freeUsers : 0); + + var cost = (users - freeUsers) * COST_PER_USER; + cost = cost * (100-pctDiscount)/100; + + return Math.max(0, cost); +} + +// currentDomainsCache + +function _cache() { + if (! appjet.cache.currentDomainsCache) { + appjet.cache.currentDomainsCache = {}; + } + return appjet.cache.currentDomainsCache; +} + +function domainCacheClear(domain) { + delete _cache()[domain]; +} + +function _domainCacheGetOrUpdate(domain, f) { + if (domain in _cache()) { + return _cache()[domain]; + } + + _cache()[domain] = f(); + return _cache()[domain]; +} + +// external API helpers + +function _getPaidThroughDate(domainId) { + return _domainCacheGetOrUpdate(domainId, function() { + var subscription = getSubscriptionForCustomer(domainId); + if (! subscription) { + return null; + } else { + return subscription.paidThrough; + } + }); +} + +// external API + +var GRACE_PERIOD_DAYS = 10; + +var CURRENT = 0; +var PAST_DUE = 1; +var SUSPENDED = 2; +var NO_BILLING_INFO = 3; + +function getDomainStatus(domainId) { + var paidThrough = _getPaidThroughDate(domainId); + + if (paidThrough == null) { + return NO_BILLING_INFO; + } + if (paidThrough.getTime() > new Date(Date.now()-86400*1000)) { + return CURRENT; + } + // less than GRACE_PERIOD_DAYS have passed since paidThrough date + if (paidThrough.getTime() > Date.now() - GRACE_PERIOD_DAYS*86400*1000) { + return PAST_DUE; + } + return SUSPENDED; +} + +function getDomainDueDate(domainId) { + return _getPaidThroughDate(domainId); +} + +function getDomainSuspensionDate(domainId) { + return new Date(_getPaidThroughDate(domainId).getTime() + GRACE_PERIOD_DAYS*86400*1000); +} + +// emails + +function sendReceiptEmail(subscription, invoice) { + var paymentInfo = getRecurringBillingInfo(subscription.customer); + var coupon = getCouponValue(subscription.coupon); + var emailText = renderTemplateAsString('email/pro_payment_receipt.ejs', { + fullName: paymentInfo.fullname, + paymentSummary: paymentInfo.paymentsummary, + expiration: checkout.formatExpiration(paymentInfo.expiration), + invoiceNumber: invoice.id, + numUsers: invoice.users, + cost: billing.centsToDollars(invoice.amt), + dollars: checkout.dollars, + coupon: coupon, + globals: globals + }); + var address = paymentInfo.email; + checkout.salesEmail(address, "sales@pad.spline.inf.fu-berlin.de", "EtherPad: Receipt for "+paymentInfo.fullname, + {}, emailText); +} + +function sendFailureEmail(subscription, invoice, failureMessage) { + var domain = subscription.customer; + var subDomain = domains.getDomainRecord(domain).subDomain; + var paymentInfo = getRecurringBillingInfo(subscription.customer); + var emailText = renderTemplateAsString('email/pro_payment_failure.ejs', { + fullName: paymentInfo.fullname, + billingError: failureMessage, + balance: "US $"+checkout.dollars(billing.centsToDollars(invoice.amt)), + suspensionDate: checkout.formatDate(new Date(subscription.paidThrough.getTime()+GRACE_PERIOD_DAYS*86400*1000)), + billingAdminLink: "https://"+subDomain+".pad.spline.inf.fu-berlin.de/ep/admin/billing/" + }); + var address = paymentInfo.email; + checkout.salesEmail(address, "sales@pad.spline.inf.fu-berlin.de", "EtherPad: Payment Failure for "+paymentInfo.fullname, + {}, emailText); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/collab/ace/contentcollector.js b/trunk/etherpad/src/etherpad/collab/ace/contentcollector.js new file mode 100644 index 0000000..5dd4f9c --- /dev/null +++ b/trunk/etherpad/src/etherpad/collab/ace/contentcollector.js @@ -0,0 +1,527 @@ +// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/contentcollector.js +import("etherpad.collab.ace.easysync2.Changeset") + +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var _MAX_LIST_LEVEL = 8; + +function sanitizeUnicode(s) { + return s.replace(/[\uffff\ufffe\ufeff\ufdd0-\ufdef\ud800-\udfff]/g, '?'); +} + +function makeContentCollector(collectStyles, browser, apool, domInterface, + className2Author) { + browser = browser || {}; + + var dom = domInterface || { + isNodeText: function(n) { + return (n.nodeType == 3); + }, + nodeTagName: function(n) { + return n.tagName; + }, + nodeValue: function(n) { + return n.nodeValue; + }, + nodeNumChildren: function(n) { + return n.childNodes.length; + }, + nodeChild: function(n, i) { + return n.childNodes.item(i); + }, + nodeProp: function(n, p) { + return n[p]; + }, + nodeAttr: function(n, a) { + return n.getAttribute(a); + }, + optNodeInnerHTML: function(n) { + return n.innerHTML; + } + }; + + var _blockElems = { "div":1, "p":1, "pre":1, "li":1 }; + function isBlockElement(n) { + return !!_blockElems[(dom.nodeTagName(n) || "").toLowerCase()]; + } + function textify(str) { + return sanitizeUnicode( + str.replace(/[\n\r ]/g, ' ').replace(/\xa0/g, ' ').replace(/\t/g, ' ')); + } + function getAssoc(node, name) { + return dom.nodeProp(node, "_magicdom_"+name); + } + + var lines = (function() { + var textArray = []; + var attribsArray = []; + var attribsBuilder = null; + var op = Changeset.newOp('+'); + var self = { + length: function() { return textArray.length; }, + atColumnZero: function() { + return textArray[textArray.length-1] === ""; + }, + startNew: function() { + textArray.push(""); + self.flush(true); + attribsBuilder = Changeset.smartOpAssembler(); + }, + textOfLine: function(i) { return textArray[i]; }, + appendText: function(txt, attrString) { + textArray[textArray.length-1] += txt; + //dmesg(txt+" / "+attrString); + op.attribs = attrString; + op.chars = txt.length; + attribsBuilder.append(op); + }, + textLines: function() { return textArray.slice(); }, + attribLines: function() { return attribsArray; }, + // call flush only when you're done + flush: function(withNewline) { + if (attribsBuilder) { + attribsArray.push(attribsBuilder.toString()); + attribsBuilder = null; + } + } + }; + self.startNew(); + return self; + }()); + var cc = {}; + function _ensureColumnZero(state) { + if (! lines.atColumnZero()) { + _startNewLine(state); + } + } + var selection, startPoint, endPoint; + var selStart = [-1,-1], selEnd = [-1,-1]; + var blockElems = { "div":1, "p":1, "pre":1 }; + function _isEmpty(node, state) { + // consider clean blank lines pasted in IE to be empty + if (dom.nodeNumChildren(node) == 0) return true; + if (dom.nodeNumChildren(node) == 1 && + getAssoc(node, "shouldBeEmpty") && dom.optNodeInnerHTML(node) == " " + && ! getAssoc(node, "unpasted")) { + if (state) { + var child = dom.nodeChild(node, 0); + _reachPoint(child, 0, state); + _reachPoint(child, 1, state); + } + return true; + } + return false; + } + function _pointHere(charsAfter, state) { + var ln = lines.length()-1; + var chr = lines.textOfLine(ln).length; + if (chr == 0 && state.listType && state.listType != 'none') { + chr += 1; // listMarker + } + chr += charsAfter; + return [ln, chr]; + } + function _reachBlockPoint(nd, idx, state) { + if (! dom.isNodeText(nd)) _reachPoint(nd, idx, state); + } + function _reachPoint(nd, idx, state) { + if (startPoint && nd == startPoint.node && startPoint.index == idx) { + selStart = _pointHere(0, state); + } + if (endPoint && nd == endPoint.node && endPoint.index == idx) { + selEnd = _pointHere(0, state); + } + } + function _incrementFlag(state, flagName) { + state.flags[flagName] = (state.flags[flagName] || 0)+1; + } + function _decrementFlag(state, flagName) { + state.flags[flagName]--; + } + function _incrementAttrib(state, attribName) { + if (! state.attribs[attribName]) { + state.attribs[attribName] = 1; + } + else { + state.attribs[attribName]++; + } + _recalcAttribString(state); + } + function _decrementAttrib(state, attribName) { + state.attribs[attribName]--; + _recalcAttribString(state); + } + function _enterList(state, listType) { + var oldListType = state.listType; + state.listLevel = (state.listLevel || 0)+1; + if (listType != 'none') { + state.listNesting = (state.listNesting || 0)+1; + } + state.listType = listType; + _recalcAttribString(state); + return oldListType; + } + function _exitList(state, oldListType) { + state.listLevel--; + if (state.listType != 'none') { + state.listNesting--; + } + state.listType = oldListType; + _recalcAttribString(state); + } + function _enterAuthor(state, author) { + var oldAuthor = state.author; + state.authorLevel = (state.authorLevel || 0)+1; + state.author = author; + _recalcAttribString(state); + return oldAuthor; + } + function _exitAuthor(state, oldAuthor) { + state.authorLevel--; + state.author = oldAuthor; + _recalcAttribString(state); + } + function _recalcAttribString(state) { + var lst = []; + for(var a in state.attribs) { + if (state.attribs[a]) { + lst.push([a,'true']); + } + } + if (state.authorLevel > 0) { + var authorAttrib = ['author', state.author]; + if (apool.putAttrib(authorAttrib, true) >= 0) { + // require that author already be in pool + // (don't add authors from other documents, etc.) + lst.push(authorAttrib); + } + } + state.attribString = Changeset.makeAttribsString('+', lst, apool); + } + function _produceListMarker(state) { + lines.appendText('*', Changeset.makeAttribsString( + '+', [['list', state.listType], + ['insertorder', 'first']], + apool)); + } + function _startNewLine(state) { + if (state) { + var atBeginningOfLine = lines.textOfLine(lines.length()-1).length == 0; + if (atBeginningOfLine && state.listType && state.listType != 'none') { + _produceListMarker(state); + } + } + lines.startNew(); + } + cc.notifySelection = function (sel) { + if (sel) { + selection = sel; + startPoint = selection.startPoint; + endPoint = selection.endPoint; + } + }; + cc.collectContent = function (node, state) { + if (! state) { + state = {flags: {/*name -> nesting counter*/}, + attribs: {/*name -> nesting counter*/}, + attribString: ''}; + } + var isBlock = isBlockElement(node); + var isEmpty = _isEmpty(node, state); + if (isBlock) _ensureColumnZero(state); + var startLine = lines.length()-1; + _reachBlockPoint(node, 0, state); + if (dom.isNodeText(node)) { + var txt = dom.nodeValue(node); + var rest = ''; + var x = 0; // offset into original text + if (txt.length == 0) { + if (startPoint && node == startPoint.node) { + selStart = _pointHere(0, state); + } + if (endPoint && node == endPoint.node) { + selEnd = _pointHere(0, state); + } + } + while (txt.length > 0) { + var consumed = 0; + if (state.flags.preMode) { + var firstLine = txt.split('\n',1)[0]; + consumed = firstLine.length+1; + rest = txt.substring(consumed); + txt = firstLine; + } + else { /* will only run this loop body once */ } + if (startPoint && node == startPoint.node && + startPoint.index-x <= txt.length) { + selStart = _pointHere(startPoint.index-x, state); + } + if (endPoint && node == endPoint.node && + endPoint.index-x <= txt.length) { + selEnd = _pointHere(endPoint.index-x, state); + } + var txt2 = txt; + if ((! state.flags.preMode) && /^[\r\n]*$/.exec(txt)) { + // prevents textnodes containing just "\n" from being significant + // in safari when pasting text, now that we convert them to + // spaces instead of removing them, because in other cases + // removing "\n" from pasted HTML will collapse words together. + txt2 = ""; + } + var atBeginningOfLine = lines.textOfLine(lines.length()-1).length == 0; + if (atBeginningOfLine) { + // newlines in the source mustn't become spaces at beginning of line box + txt2 = txt2.replace(/^\n*/, ''); + } + if (atBeginningOfLine && state.listType && state.listType != 'none') { + _produceListMarker(state); + } + lines.appendText(textify(txt2), state.attribString); + x += consumed; + txt = rest; + if (txt.length > 0) { + _startNewLine(state); + } + } + } + else { + var tname = (dom.nodeTagName(node) || "").toLowerCase(); + if (tname == "br") { + _startNewLine(state); + } + else if (tname == "script" || tname == "style") { + // ignore + } + else if (! isEmpty) { + var styl = dom.nodeAttr(node, "style"); + var cls = dom.nodeProp(node, "className"); + + var isPre = (tname == "pre"); + if ((! isPre) && browser.safari) { + isPre = (styl && /\bwhite-space:\s*pre\b/i.exec(styl)); + } + if (isPre) _incrementFlag(state, 'preMode'); + var attribs = null; + var oldListTypeOrNull = null; + var oldAuthorOrNull = null; + if (collectStyles) { + function doAttrib(na) { + attribs = (attribs || []); + attribs.push(na); + _incrementAttrib(state, na); + } + if (tname == "b" || (styl && /\bfont-weight:\s*bold\b/i.exec(styl)) || + tname == "strong") { + doAttrib("bold"); + } + if (tname == "i" || (styl && /\bfont-style:\s*italic\b/i.exec(styl)) || + tname == "em") { + doAttrib("italic"); + } + if (tname == "u" || (styl && /\btext-decoration:\s*underline\b/i.exec(styl)) || + tname == "ins") { + doAttrib("underline"); + } + if (tname == "s" || (styl && /\btext-decoration:\s*line-through\b/i.exec(styl)) || + tname == "del") { + doAttrib("strikethrough"); + } + if (tname == "h1") { + doAttrib("h1"); + } + if (tname == "h2") { + doAttrib("h2"); + } + if (tname == "h3") { + doAttrib("h3"); + } + if (tname == "h4") { + doAttrib("h4"); + } + if (tname == "h5") { + doAttrib("h5"); + } + if (tname == "h6") { + doAttrib("h6"); + } + if (tname == "ul") { + var type; + var rr = cls && /(?:^| )list-(bullet[12345678])\b/.exec(cls); + type = rr && rr[1] || "bullet"+ + String(Math.min(_MAX_LIST_LEVEL, (state.listNesting||0)+1)); + oldListTypeOrNull = (_enterList(state, type) || 'none'); + } + else if ((tname == "div" || tname == "p") && cls && + cls.match(/(?:^| )ace-line\b/)) { + oldListTypeOrNull = (_enterList(state, type) || 'none'); + } + if (className2Author && cls) { + var classes = cls.match(/\S+/g); + if (classes && classes.length > 0) { + for(var i=0;i<classes.length;i++) { + var c = classes[i]; + var a = className2Author(c); + if (a) { + oldAuthorOrNull = (_enterAuthor(state, a) || 'none'); + break; + } + } + } + } + } + + var nc = dom.nodeNumChildren(node); + for(var i=0;i<nc;i++) { + var c = dom.nodeChild(node, i); + cc.collectContent(c, state); + } + + if (isPre) _decrementFlag(state, 'preMode'); + if (attribs) { + for(var i=0;i<attribs.length;i++) { + _decrementAttrib(state, attribs[i]); + } + } + if (oldListTypeOrNull) { + _exitList(state, oldListTypeOrNull); + } + if (oldAuthorOrNull) { + _exitAuthor(state, oldAuthorOrNull); + } + } + } + if (! browser.msie) { + _reachBlockPoint(node, 1, state); + } + if (isBlock) { + if (lines.length()-1 == startLine) { + _startNewLine(state); + } + else { + _ensureColumnZero(state); + } + } + + if (browser.msie) { + // in IE, a point immediately after a DIV appears on the next line + _reachBlockPoint(node, 1, state); + } + }; + // can pass a falsy value for end of doc + cc.notifyNextNode = function (node) { + // an "empty block" won't end a line; this addresses an issue in IE with + // typing into a blank line at the end of the document. typed text + // goes into the body, and the empty line div still looks clean. + // it is incorporated as dirty by the rule that a dirty region has + // to end a line. + if ((!node) || (isBlockElement(node) && !_isEmpty(node))) { + _ensureColumnZero(null); + } + }; + // each returns [line, char] or [-1,-1] + var getSelectionStart = function() { return selStart; }; + var getSelectionEnd = function() { return selEnd; }; + + // returns array of strings for lines found, last entry will be "" if + // last line is complete (i.e. if a following span should be on a new line). + // can be called at any point + cc.getLines = function() { return lines.textLines(); }; + + //cc.applyHints = function(hints) { + //if (hints.pastedLines) { + // + //} + //} + + cc.finish = function() { + lines.flush(); + var lineAttribs = lines.attribLines(); + var lineStrings = cc.getLines(); + + lineStrings.length--; + lineAttribs.length--; + + var ss = getSelectionStart(); + var se = getSelectionEnd(); + + function fixLongLines() { + // design mode does not deal with with really long lines! + var lineLimit = 2000; // chars + var buffer = 10; // chars allowed over before wrapping + var linesWrapped = 0; + var numLinesAfter = 0; + for(var i=lineStrings.length-1; i>=0; i--) { + var oldString = lineStrings[i]; + var oldAttribString = lineAttribs[i]; + if (oldString.length > lineLimit+buffer) { + var newStrings = []; + var newAttribStrings = []; + while (oldString.length > lineLimit) { + //var semiloc = oldString.lastIndexOf(';', lineLimit-1); + //var lengthToTake = (semiloc >= 0 ? (semiloc+1) : lineLimit); + lengthToTake = lineLimit; + newStrings.push(oldString.substring(0, lengthToTake)); + oldString = oldString.substring(lengthToTake); + newAttribStrings.push(Changeset.subattribution(oldAttribString, + 0, lengthToTake)); + oldAttribString = Changeset.subattribution(oldAttribString, + lengthToTake); + } + if (oldString.length > 0) { + newStrings.push(oldString); + newAttribStrings.push(oldAttribString); + } + function fixLineNumber(lineChar) { + if (lineChar[0] < 0) return; + var n = lineChar[0]; + var c = lineChar[1]; + if (n > i) { + n += (newStrings.length-1); + } + else if (n == i) { + var a = 0; + while (c > newStrings[a].length) { + c -= newStrings[a].length; + a++; + } + n += a; + } + lineChar[0] = n; + lineChar[1] = c; + } + fixLineNumber(ss); + fixLineNumber(se); + linesWrapped++; + numLinesAfter += newStrings.length; + + newStrings.unshift(i, 1); + lineStrings.splice.apply(lineStrings, newStrings); + newAttribStrings.unshift(i, 1); + lineAttribs.splice.apply(lineAttribs, newAttribStrings); + } + } + return {linesWrapped:linesWrapped, numLinesAfter:numLinesAfter}; + } + var wrapData = fixLongLines(); + + return { selStart: ss, selEnd: se, linesWrapped: wrapData.linesWrapped, + numLinesAfter: wrapData.numLinesAfter, + lines: lineStrings, lineAttribs: lineAttribs }; + } + + return cc; +} diff --git a/trunk/etherpad/src/etherpad/collab/ace/domline.js b/trunk/etherpad/src/etherpad/collab/ace/domline.js new file mode 100644 index 0000000..de2e7d3 --- /dev/null +++ b/trunk/etherpad/src/etherpad/collab/ace/domline.js @@ -0,0 +1,210 @@ +// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/domline.js + +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var domline = {}; +domline.noop = function() {}; +domline.identity = function(x) { return x; }; + +domline.addToLineClass = function(lineClass, cls) { + // an "empty span" at any point can be used to add classes to + // the line, using line:className. otherwise, we ignore + // the span. + cls.replace(/\S+/g, function (c) { + if (c.indexOf("line:") == 0) { + // add class to line + lineClass = (lineClass ? lineClass+' ' : '')+c.substring(5); + } + }); + return lineClass; +} + +// if "document" is falsy we don't create a DOM node, just +// an object with innerHTML and className +domline.createDomLine = function(nonEmpty, doesWrap, optBrowser, optDocument) { + var result = { node: null, + appendSpan: domline.noop, + prepareForAdd: domline.noop, + notifyAdded: domline.noop, + clearSpans: domline.noop, + finishUpdate: domline.noop, + lineMarker: 0 }; + + var browser = (optBrowser || {}); + var document = optDocument; + + if (document) { + result.node = document.createElement("div"); + } + else { + result.node = {innerHTML: '', className: ''}; + } + + var html = []; + var preHtml, postHtml; + var curHTML = null; + function processSpaces(s) { + return domline.processSpaces(s, doesWrap); + } + var identity = domline.identity; + var perTextNodeProcess = (doesWrap ? identity : processSpaces); + var perHtmlLineProcess = (doesWrap ? processSpaces : identity); + var lineClass = 'ace-line'; + result.appendSpan = function(txt, cls) { + if (cls.indexOf('list') >= 0) { + var listType = /(?:^| )list:(\S+)/.exec(cls); + if (listType) { + listType = listType[1]; + if (listType) { + preHtml = '<ul class="list-'+listType+'"><li>'; + postHtml = '</li></ul>'; + } + result.lineMarker += txt.length; + return; // don't append any text + } + } + var href = null; + var simpleTags = null; + if (cls.indexOf('url') >= 0) { + cls = cls.replace(/(^| )url:(\S+)/g, function(x0, space, url) { + href = url; + return space+"url"; + }); + } + if (cls.indexOf('tag') >= 0) { + cls = cls.replace(/(^| )tag:(\S+)/g, function(x0, space, tag) { + if (! simpleTags) simpleTags = []; + simpleTags.push(tag.toLowerCase()); + return space+tag; + }); + } + if ((! txt) && cls) { + lineClass = domline.addToLineClass(lineClass, cls); + } + else if (txt) { + var extraOpenTags = ""; + var extraCloseTags = ""; + if (href) { + extraOpenTags = extraOpenTags+'<a href="'+ + href.replace(/\"/g, '"')+'">'; + extraCloseTags = '</a>'+extraCloseTags; + } + if (simpleTags) { + simpleTags.sort(); + extraOpenTags = extraOpenTags+'<'+simpleTags.join('><')+'>'; + simpleTags.reverse(); + extraCloseTags = '</'+simpleTags.join('></')+'>'+extraCloseTags; + } + html.push('<span class="',cls||'','">',extraOpenTags, + perTextNodeProcess(domline.escapeHTML(txt)), + extraCloseTags,'</span>'); + } + }; + result.clearSpans = function() { + html = []; + lineClass = ''; // non-null to cause update + result.lineMarker = 0; + }; + function writeHTML() { + var newHTML = perHtmlLineProcess(html.join('')); + if (! newHTML) { + if ((! document) || (! optBrowser)) { + newHTML += ' '; + } + else if (! browser.msie) { + newHTML += '<br/>'; + } + } + if (nonEmpty) { + newHTML = (preHtml||'')+newHTML+(postHtml||''); + } + html = preHtml = postHtml = null; // free memory + if (newHTML !== curHTML) { + curHTML = newHTML; + result.node.innerHTML = curHTML; + } + if (lineClass !== null) result.node.className = lineClass; + } + result.prepareForAdd = writeHTML; + result.finishUpdate = writeHTML; + result.getInnerHTML = function() { return curHTML || ''; }; + + return result; +}; + +domline.escapeHTML = function(s) { + var re = /[&<>'"]/g; /']/; // stupid indentation thing + if (! re.MAP) { + // persisted across function calls! + re.MAP = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + } + return s.replace(re, function(c) { return re.MAP[c]; }); +}; + +domline.processSpaces = function(s, doesWrap) { + if (s.indexOf("<") < 0 && ! doesWrap) { + // short-cut + return s.replace(/ /g, ' '); + } + var parts = []; + s.replace(/<[^>]*>?| |[^ <]+/g, function(m) { parts.push(m); }); + if (doesWrap) { + var endOfLine = true; + var beforeSpace = false; + // last space in a run is normal, others are nbsp, + // end of line is nbsp + for(var i=parts.length-1;i>=0;i--) { + var p = parts[i]; + if (p == " ") { + if (endOfLine || beforeSpace) + parts[i] = ' '; + endOfLine = false; + beforeSpace = true; + } + else if (p.charAt(0) != "<") { + endOfLine = false; + beforeSpace = false; + } + } + // beginning of line is nbsp + for(var i=0;i<parts.length;i++) { + var p = parts[i]; + if (p == " ") { + parts[i] = ' '; + break; + } + else if (p.charAt(0) != "<") { + break; + } + } + } + else { + for(var i=0;i<parts.length;i++) { + var p = parts[i]; + if (p == " ") { + parts[i] = ' '; + } + } + } + return parts.join(''); +}; diff --git a/trunk/etherpad/src/etherpad/collab/ace/easysync1.js b/trunk/etherpad/src/etherpad/collab/ace/easysync1.js new file mode 100644 index 0000000..4f40aa0 --- /dev/null +++ b/trunk/etherpad/src/etherpad/collab/ace/easysync1.js @@ -0,0 +1,923 @@ +// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/easy_sync.js + +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +function Changeset(arg) { + + var array; + if ((typeof arg) == "string") { + // constant + array = [Changeset.MAGIC, 0, arg.length, 0, 0, arg]; + } + else if ((typeof arg) == "number") { + var n = Math.round(arg); + // delete-all on n-length text (useful for making a "builder") + array = [Changeset.MAGIC, n, 0, 0, 0, ""]; + } + else if (! arg) { + // identity on 0-length text + array = [Changeset.MAGIC, 0, 0, 0, 0, ""]; + } + else if (arg.isChangeset) { + return arg; + } + else array = arg; + + array.isChangeset = true; + + // OOP style: attach generic methods to array object, hold no state in environment + + //function error(msg) { top.console.error(msg); top.console.trace(); } + function error(msg) { var e = new Error(msg); e.easysync = true; throw e; } + function assert(b, msg) { if (! b) error("Changeset: "+String(msg)); } + function min(x, y) { return (x < y) ? x : y; } + Changeset._assert = assert; + + array.isIdentity = function() { + return this.length == 6 && this[1] == this[2] && this[3] == 0 && + this[4] == this[1] && this[5] == ""; + } + + array.eachStrip = function(func, thisObj) { + // inside "func", the method receiver will be "this" by default, + // or you can pass an object. + for(var i=0;i<this.numStrips();i++) { + var ptr = 3 + i*3; + if (func.call(thisObj || this, this[ptr], this[ptr+1], this[ptr+2], i)) + return true; + } + return false; + } + + array.numStrips = function() { return (this.length-3)/3; }; + array.oldLen = function() { return this[1]; }; + array.newLen = function() { return this[2]; }; + + array.checkRep = function() { + assert(this[0] == Changeset.MAGIC, "bad magic"); + assert(this[1] >= 0, "bad old text length"); + assert(this[2] >= 0, "bad new text length"); + assert((this.length % 3) == 0, "bad array length"); + assert(this.length >= 6, "must be at least one strip"); + var numStrips = this.numStrips(); + var oldLen = this[1]; + var newLen = this[2]; + // iterate over the "text strips" + var actualNewLen = 0; + this.eachStrip(function(startIndex, numTaken, newText, i) { + var s = startIndex, t = numTaken, n = newText; + var isFirst = (i == 0); + var isLast = (i == numStrips-1); + assert(t >= 0, "can't take negative number of chars"); + assert(isFirst || t > 0, "all strips but first must take"); + assert((t > 0) || (s == 0), "if first strip doesn't take, must have 0 startIndex"); + assert(s >= 0 && s + t <= oldLen, "bad index: "+this.toString()); + assert(t > 0 || n.length > 0 || (isFirst && isLast), "empty strip must be first and only"); + if (! isLast) { + var s2 = this[3 + i*3 + 3]; // startIndex of following strip + var gap = s2 - (s + t); + assert(gap >= 0, "overlapping or out-of-order strips: "+this.toString()); + assert(gap > 0 || n.length > 0, "touching strips with no added text"); + } + actualNewLen += t + n.length; + }); + assert(newLen == actualNewLen, "calculated new text length doesn't match"); + } + + array.applyToText = function(text) { + assert(text.length == this.oldLen(), "mismatched apply: "+text.length+" / "+this.oldLen()); + var buf = []; + this.eachStrip(function (s, t, n) { + buf.push(text.substr(s, t), n); + }); + return buf.join(''); + } + + function _makeBuilder(oldLen, supportAuthors) { + var C = Changeset(oldLen); + if (supportAuthors) { + _ensureAuthors(C); + } + return C.builder(); + } + + function _getNumInserted(C) { + var numChars = 0; + C.eachStrip(function(s,t,n) { + numChars += n.length; + }); + return numChars; + } + + function _ensureAuthors(C) { + if (! C.authors) { + C.setAuthor(); + } + return C; + } + + array.setAuthor = function(author) { + var C = this; + // authors array has even length >= 2; + // alternates [numChars1, author1, numChars2, author2]; + // all numChars > 0 unless there is exactly one, in which + // case it can be == 0. + C.authors = [_getNumInserted(C), author || '']; + return C; + } + + array.builder = function() { + // normal pattern is Changeset(oldLength).builder().appendOldText(...). ... + // builder methods mutate this! + var C = this; + // OOP style: state in environment + var self; + return self = { + appendNewText: function(str, author) { + C[C.length-1] += str; + C[2] += str.length; + + if (C.authors) { + var a = (author || ''); + var lastAuthorPtr = C.authors.length-1; + var lastAuthorLengthPtr = C.authors.length-2; + if ((!a) || a == C.authors[lastAuthorPtr]) { + C.authors[lastAuthorLengthPtr] += str.length; + } + else if (0 == C.authors[lastAuthorLengthPtr]) { + C.authors[lastAuthorLengthPtr] = str.length; + C.authors[lastAuthorPtr] = (a || C.authors[lastAuthorPtr]); + } + else { + C.authors.push(str.length, a); + } + } + + return self; + }, + appendOldText: function(startIndex, numTaken) { + if (numTaken == 0) return self; + // properties of last strip... + var s = C[C.length-3], t = C[C.length-2], n = C[C.length-1]; + if (t == 0 && n == "") { + // must be empty changeset, one strip that doesn't take old chars or add new ones + C[C.length-3] = startIndex; + C[C.length-2] = numTaken; + } + else if (n == "" && (s+t == startIndex)) { + C[C.length-2] += numTaken; // take more + } + else C.push(startIndex, numTaken, ""); // add a strip + C[2] += numTaken; + C.checkRep(); + return self; + }, + toChangeset: function() { return C; } + }; + } + + array.authorSlicer = function(outputBuilder) { + return _makeAuthorSlicer(this, outputBuilder); + } + + function _makeAuthorSlicer(changesetOrAuthorsIn, builderOut) { + // "builderOut" only needs to support appendNewText + var authors; // considered immutable + if (changesetOrAuthorsIn.isChangeset) { + authors = changesetOrAuthorsIn.authors; + } + else { + authors = changesetOrAuthorsIn; + } + + // OOP style: state in environment + var authorPtr = 0; + var charIndex = 0; + var charWithinAuthor = 0; // 0 <= charWithinAuthor <= authors[authorPtr]; max value iff atEnd + var atEnd = false; + function curAuthor() { return authors[authorPtr+1]; } + function curAuthorWidth() { return authors[authorPtr]; } + function assertNotAtEnd() { assert(! atEnd, "_authorSlicer: can't move past end"); } + function forwardInAuthor(numChars) { + charWithinAuthor += numChars; + charIndex += numChars; + } + function nextAuthor() { + assertNotAtEnd(); + assert(charWithinAuthor == curAuthorWidth(), "_authorSlicer: not at author end"); + charWithinAuthor = 0; + authorPtr += 2; + if (authorPtr == authors.length) { + atEnd = true; + } + } + + var self; + return self = { + skipChars: function(n) { + assert(n >= 0, "_authorSlicer: can't skip negative n"); + if (n == 0) return; + assertNotAtEnd(); + + var leftToSkip = n; + while (leftToSkip > 0) { + var leftInAuthor = curAuthorWidth() - charWithinAuthor; + if (leftToSkip >= leftInAuthor) { + forwardInAuthor(leftInAuthor); + leftToSkip -= leftInAuthor; + nextAuthor(); + } + else { + forwardInAuthor(leftToSkip); + leftToSkip = 0; + } + } + }, + takeChars: function(n, text) { + assert(n >= 0, "_authorSlicer: can't take negative n"); + if (n == 0) return; + assertNotAtEnd(); + assert(n == text.length, "_authorSlicer: bad text length"); + + var textLeft = text; + var leftToTake = n; + while (leftToTake > 0) { + if (curAuthorWidth() > 0 && charWithinAuthor < curAuthorWidth()) { + // at least one char to take from current author + var leftInAuthor = (curAuthorWidth() - charWithinAuthor); + assert(leftInAuthor > 0, "_authorSlicer: should have leftInAuthor > 0"); + var toTake = min(leftInAuthor, leftToTake); + assert(toTake > 0, "_authorSlicer: should have toTake > 0"); + builderOut.appendNewText(textLeft.substring(0, toTake), curAuthor()); + forwardInAuthor(toTake); + leftToTake -= toTake; + textLeft = textLeft.substring(toTake); + } + assert(charWithinAuthor <= curAuthorWidth(), "_authorSlicer: past end of author"); + if (charWithinAuthor == curAuthorWidth()) { + nextAuthor(); + } + } + }, + setBuilder: function(builder) { + builderOut = builder; + } + }; + } + + function _makeSlicer(C, output) { + // C: Changeset, output: builder from _makeBuilder + // C is considered immutable, won't change or be changed + + // OOP style: state in environment + var charIndex = 0; // 0 <= charIndex <= C.newLen(); maximum value iff atEnd + var stripIndex = 0; // 0 <= stripIndex <= C.numStrips(); maximum value iff atEnd + var charWithinStrip = 0; // 0 <= charWithinStrip < curStripWidth() + var atEnd = false; + + var authorSlicer; + if (C.authors) { + authorSlicer = _makeAuthorSlicer(C.authors, output); + } + + var ptr = 3; + function curStartIndex() { return C[ptr]; } + function curNumTaken() { return C[ptr+1]; } + function curNewText() { return C[ptr+2]; } + function curStripWidth() { return curNumTaken() + curNewText().length; } + function assertNotAtEnd() { assert(! atEnd, "_slicer: can't move past changeset end"); } + function forwardInStrip(numChars) { + charWithinStrip += numChars; + charIndex += numChars; + } + function nextStrip() { + assertNotAtEnd(); + assert(charWithinStrip == curStripWidth(), "_slicer: not at strip end"); + charWithinStrip = 0; + stripIndex++; + ptr += 3; + if (stripIndex == C.numStrips()) { + atEnd = true; + } + } + function curNumNewCharsInRange(start, end) { + // takes two indices into the current strip's combined "taken" and "new" + // chars, and returns how many "new" chars are included in the range + assert(start <= end, "_slicer: curNumNewCharsInRange given out-of-order indices"); + var nt = curNumTaken(); + var nn = curNewText().length; + var s = nt; + var e = nt+nn; + if (s < start) s = start; + if (e > end) e = end; + if (e < s) return 0; + return e-s; + } + + var self; + return self = { + skipChars: function (n) { + assert(n >= 0, "_slicer: can't skip negative n"); + if (n == 0) return; + assertNotAtEnd(); + + var leftToSkip = n; + while (leftToSkip > 0) { + var leftInStrip = curStripWidth() - charWithinStrip; + if (leftToSkip >= leftInStrip) { + forwardInStrip(leftInStrip); + + if (authorSlicer) + authorSlicer.skipChars(curNumNewCharsInRange(charWithinStrip, + charWithinStrip + leftInStrip)); + + leftToSkip -= leftInStrip; + nextStrip(); + } + else { + if (authorSlicer) + authorSlicer.skipChars(curNumNewCharsInRange(charWithinStrip, + charWithinStrip + leftToSkip)); + + forwardInStrip(leftToSkip); + leftToSkip = 0; + } + } + }, + takeChars: function (n) { + assert(n >= 0, "_slicer: can't take negative n"); + if (n == 0) return; + assertNotAtEnd(); + + var leftToTake = n; + while (leftToTake > 0) { + if (curNumTaken() > 0 && charWithinStrip < curNumTaken()) { + // at least one char to take from current strip's numTaken + var leftInTaken = (curNumTaken() - charWithinStrip); + assert(leftInTaken > 0, "_slicer: should have leftInTaken > 0"); + var toTake = min(leftInTaken, leftToTake); + assert(toTake > 0, "_slicer: should have toTake > 0"); + output.appendOldText(curStartIndex() + charWithinStrip, toTake); + forwardInStrip(toTake); + leftToTake -= toTake; + } + if (leftToTake > 0 && curNewText().length > 0 && charWithinStrip >= curNumTaken() && + charWithinStrip < curStripWidth()) { + // at least one char to take from current strip's newText + var leftInNewText = (curStripWidth() - charWithinStrip); + assert(leftInNewText > 0, "_slicer: should have leftInNewText > 0"); + var toTake = min(leftInNewText, leftToTake); + assert(toTake > 0, "_slicer: should have toTake > 0"); + var newText = curNewText().substr(charWithinStrip - curNumTaken(), toTake); + if (authorSlicer) { + authorSlicer.takeChars(newText.length, newText); + } + else { + output.appendNewText(newText); + } + forwardInStrip(toTake); + leftToTake -= toTake; + } + assert(charWithinStrip <= curStripWidth(), "_slicer: past end of strip"); + if (charWithinStrip == curStripWidth()) { + nextStrip(); + } + } + }, + skipTo: function(n) { + self.skipChars(n - charIndex); + } + }; + } + + array.slicer = function(outputBuilder) { + return _makeSlicer(this, outputBuilder); + } + + array.compose = function(next) { + assert(next.oldLen() == this.newLen(), "mismatched composition"); + + var builder = _makeBuilder(this.oldLen(), !!(this.authors || next.authors)); + var slicer = _makeSlicer(this, builder); + + var authorSlicer; + if (next.authors) { + authorSlicer = _makeAuthorSlicer(next.authors, builder); + } + + next.eachStrip(function(s, t, n) { + slicer.skipTo(s); + slicer.takeChars(t); + if (authorSlicer) { + authorSlicer.takeChars(n.length, n); + } + else { + builder.appendNewText(n); + } + }, this); + + return builder.toChangeset(); + }; + + array.traverser = function() { + return _makeTraverser(this); + } + + function _makeTraverser(C) { + var s = C[3], t = C[4], n = C[5]; + var nextIndex = 6; + var indexIntoNewText = 0; + + var authorSlicer; + if (C.authors) { + authorSlicer = _makeAuthorSlicer(C.authors, null); + } + + function advanceIfPossible() { + if (t == 0 && n == "" && nextIndex < C.length) { + s = C[nextIndex]; + t = C[nextIndex+1]; + n = C[nextIndex+2]; + nextIndex += 3; + } + } + + var self; + return self = { + numTakenChars: function() { + // if starts with taken characters, then how many, else 0 + return (t > 0) ? t : 0; + }, + numNewChars: function() { + // if starts with new characters, then how many, else 0 + return (t == 0 && n.length > 0) ? n.length : 0; + }, + takenCharsStart: function() { + return (self.numTakenChars() > 0) ? s : 0; + }, + hasMore: function() { + return self.numTakenChars() > 0 || self.numNewChars() > 0; + }, + curIndex: function() { + return indexIntoNewText; + }, + consumeTakenChars: function (x) { + assert(self.numTakenChars() > 0, "_traverser: no taken chars"); + assert(x >= 0 && x <= self.numTakenChars(), "_traverser: bad number of taken chars"); + if (x == 0) return; + if (t == x) { s = 0; t = 0; } + else { s += x; t -= x; } + indexIntoNewText += x; + advanceIfPossible(); + }, + consumeNewChars: function(x) { + return self.appendNewChars(x, null); + }, + appendNewChars: function(x, builder) { + assert(self.numNewChars() > 0, "_traverser: no new chars"); + assert(x >= 0 && x <= self.numNewChars(), "_traverser: bad number of new chars"); + if (x == 0) return ""; + var str = n.substring(0, x); + n = n.substring(x); + indexIntoNewText += x; + advanceIfPossible(); + + if (builder) { + if (authorSlicer) { + authorSlicer.setBuilder(builder); + authorSlicer.takeChars(x, str); + } + else { + builder.appendNewText(str); + } + } + else { + if (authorSlicer) authorSlicer.skipChars(x); + return str; + } + }, + consumeAvailableTakenChars: function() { + return self.consumeTakenChars(self.numTakenChars()); + }, + consumeAvailableNewChars: function() { + return self.consumeNewChars(self.numNewChars()); + }, + appendAvailableNewChars: function(builder) { + return self.appendNewChars(self.numNewChars(), builder); + } + }; + } + + array.follow = function(prev, reverseInsertOrder) { + // prev: Changeset, reverseInsertOrder: boolean + + // A.compose(B.follow(A)) is the merging of Changesets A and B, which operate on the same old text. + // It is always the same as B.compose(A.follow(B, true)). + + assert(prev.oldLen() == this.oldLen(), "mismatched follow: "+prev.oldLen()+"/"+this.oldLen()); + var builder = _makeBuilder(prev.newLen(), !! this.authors); + var a = _makeTraverser(prev); + var b = _makeTraverser(this); + while (a.hasMore() || b.hasMore()) { + if (a.numNewChars() > 0 && ! reverseInsertOrder) { + builder.appendOldText(a.curIndex(), a.numNewChars()); + a.consumeAvailableNewChars(); + } + else if (b.numNewChars() > 0) { + b.appendAvailableNewChars(builder); + } + else if (a.numNewChars() > 0 && reverseInsertOrder) { + builder.appendOldText(a.curIndex(), a.numNewChars()); + a.consumeAvailableNewChars(); + } + else if (! b.hasMore()) a.consumeAvailableTakenChars(); + else if (! a.hasMore()) b.consumeAvailableTakenChars(); + else { + var x = a.takenCharsStart(); + var y = b.takenCharsStart(); + if (x < y) a.consumeTakenChars(min(a.numTakenChars(), y-x)); + else if (y < x) b.consumeTakenChars(min(b.numTakenChars(), x-y)); + else { + var takenByBoth = min(a.numTakenChars(), b.numTakenChars()); + builder.appendOldText(a.curIndex(), takenByBoth); + a.consumeTakenChars(takenByBoth); + b.consumeTakenChars(takenByBoth); + } + } + } + return builder.toChangeset(); + } + + array.encodeToString = function(asBinary) { + var stringDataArray = []; + var numsArray = []; + if (! asBinary) numsArray.push(this[0]); + numsArray.push(this[1], this[2]); + this.eachStrip(function(s, t, n) { + numsArray.push(s, t, n.length); + stringDataArray.push(n); + }, this); + if (! asBinary) { + return numsArray.join(',')+'|'+stringDataArray.join(''); + } + else { + return "A" + Changeset.numberArrayToString(numsArray) + +escapeCrazyUnicode(stringDataArray.join('')); + } + } + + function escapeCrazyUnicode(str) { + return str.replace(/\\/g, '\\\\').replace(/[\ud800-\udfff]/g, function (c) { + return "\\u"+("0000"+c.charCodeAt(0).toString(16)).slice(-4); + }); + } + + array.applyToAttributedText = Changeset.applyToAttributedText; + + function splicesFromChanges(c) { + var splices = []; + // get a list of splices, [startChar, endChar, newText] + var traverser = c.traverser(); + var oldTextLength = c.oldLen(); + var indexIntoOldText = 0; + while (traverser.hasMore() || indexIntoOldText < oldTextLength) { + var newText = ""; + var startChar = indexIntoOldText; + var endChar = indexIntoOldText; + if (traverser.numNewChars() > 0) { + newText = traverser.consumeAvailableNewChars(); + } + if (traverser.hasMore()) { + endChar = traverser.takenCharsStart(); + indexIntoOldText = endChar + traverser.numTakenChars(); + traverser.consumeAvailableTakenChars(); + } + else { + endChar = oldTextLength; + indexIntoOldText = endChar; + } + if (endChar != startChar || newText.length > 0) { + splices.push([startChar, endChar, newText]); + } + } + return splices; + } + + array.toSplices = function() { + return splicesFromChanges(this); + } + + array.characterRangeFollowThis = function(selStartChar, selEndChar, insertionsAfter) { + var changeset = this; + // represent the selection as a changeset that replaces the selection with some finite string. + // Because insertions indicate intention, it doesn't matter what this string is, and even + // if the selectionChangeset is made to "follow" other changes it will still be the only + // insertion. + var selectionChangeset = + Changeset(changeset.oldLen()).builder().appendOldText(0, selStartChar).appendNewText( + "X").appendOldText(selEndChar, changeset.oldLen() - selEndChar).toChangeset(); + var newSelectionChangeset = selectionChangeset.follow(changeset, insertionsAfter); + var selectionSplices = newSelectionChangeset.toSplices(); + function includeChar(i) { + if (! includeChar.calledYet) { + selStartChar = i; + selEndChar = i; + includeChar.calledYet = true; + } + else { + if (i < selStartChar) selStartChar = i; + if (i > selEndChar) selEndChar = i; + } + } + for(var i=0; i<selectionSplices.length; i++) { + var s = selectionSplices[i]; + includeChar(s[0]); + includeChar(s[1]); + } + return [selStartChar, selEndChar]; + } + + return array; +} + +Changeset.MAGIC = "Changeset"; +Changeset.makeSplice = function(oldLength, spliceStart, numRemoved, stringInserted) { + oldLength = (oldLength || 0); + spliceStart = (spliceStart || 0); + numRemoved = (numRemoved || 0); + stringInserted = String(stringInserted || ""); + + var builder = Changeset(oldLength).builder(); + builder.appendOldText(0, spliceStart); + builder.appendNewText(stringInserted); + builder.appendOldText(spliceStart + numRemoved, oldLength - numRemoved - spliceStart); + return builder.toChangeset(); +}; +Changeset.identity = function(len) { + return Changeset(len).builder().appendOldText(0, len).toChangeset(); +}; +Changeset.decodeFromString = function(str) { + function error(msg) { var e = new Error(msg); e.easysync = true; throw e; } + function toHex(str) { + var a = []; + a.push("length["+str.length+"]:"); + var TRUNC=20; + for(var i=0;i<str.substring(0,TRUNC).length;i++) { + a.push(("000"+str.charCodeAt(i).toString(16)).slice(-4)); + } + if (str.length > TRUNC) a.push("..."); + return a.join(' '); + } + function unescapeCrazyUnicode(str) { + return str.replace(/\\(u....|\\)/g, function(seq) { + if (seq == "\\\\") return "\\"; + return String.fromCharCode(Number("0x"+seq.substring(2))); + }); + } + + var numData, stringData; + var binary = false; + var typ = str.charAt(0); + if (typ == "B" || typ == "A") { + var result = Changeset.numberArrayFromString(str, 1); + numData = result[0]; + stringData = result[1]; + if (typ == "A") { + stringData = unescapeCrazyUnicode(stringData); + } + binary = true; + } + else if (typ == "C") { + var barPosition = str.indexOf('|'); + numData = str.substring(0, barPosition).split(','); + stringData = str.substring(barPosition+1); + } + else { + error("Not a changeset: "+toHex(str)); + } + var stringDataOffset = 0; + var array = []; + var ptr; + if (binary) { + array.push("Changeset", numData[0], numData[1]); + var ptr = 2; + } + else { + array.push(numData[0], Number(numData[1]), Number(numData[2])); + var ptr = 3; + } + while (ptr < numData.length) { + array.push(Number(numData[ptr++]), Number(numData[ptr++])); + var newTextLength = Number(numData[ptr++]); + array.push(stringData.substr(stringDataOffset, newTextLength)); + stringDataOffset += newTextLength; + } + if (stringDataOffset != stringData.length) { + error("Extra character data beyond end of encoded string ("+toHex(str)+")"); + } + return Changeset(array); +}; + +Changeset.numberArrayToString = function(nums) { + var array = []; + function writeNum(n) { + // does not support negative numbers + var twentyEightBit = (n & 0xfffffff); + if (twentyEightBit <= 0x7fff) { + array.push(String.fromCharCode(twentyEightBit)); + } + else { + array.push(String.fromCharCode(0xa000 | (twentyEightBit >> 15), + twentyEightBit & 0x7fff)); + } + } + writeNum(nums.length); + var len = nums.length; + for(var i=0;i<len;i++) { + writeNum(nums[i]); + } + return array.join(''); +}; + +Changeset.numberArrayFromString = function(str, startIndex) { + // returns [numberArray, remainingString] + var nums = []; + var strIndex = (startIndex || 0); + function readNum() { + var n = str.charCodeAt(strIndex++); + if (n > 0x7fff) { + if (n >= 0xa000) { + n = (((n & 0x1fff) << 15) | str.charCodeAt(strIndex++)); + } + else { + // legacy format + n = (((n & 0x1fff) << 16) | str.charCodeAt(strIndex++)); + } + } + return n; + } + var len = readNum(); + for(var i=0;i<len;i++) { + nums.push(readNum()); + } + return [nums, str.substring(strIndex)]; +}; + +(function() { + function repeatString(str, times) { + if (times <= 0) return ""; + var s = repeatString(str, times >> 1); + s += s; + if (times & 1) s += str; + return s; + } + function chr(n) { return String.fromCharCode(n+48); } + function ord(c) { return c.charCodeAt(0)-48; } + function runMatcher(c) { + // Takes "A" and returns /\u0041+/g . + // Avoid creating new objects unnecessarily by caching matchers + // as properties of this function. + var re = runMatcher[c]; + if (re) return re; + re = runMatcher[c] = new RegExp("\\u"+("0000"+c.charCodeAt(0).toString(16)).slice(-4)+"+", 'g'); + return re; + } + function runLength(str, idx, c) { + var re = runMatcher(c); + re.lastIndex = idx; + var result = re.exec(str); + if (result && result[0]) { + return result[0].length; + } + return 0; + } + + // emptyObj may be a StorableObject + Changeset.initAttributedText = function(emptyObj, initialString, initialAuthor) { + var obj = emptyObj; + obj.authorMap = { 1: (initialAuthor || '') }; + obj.text = (initialString || ''); + obj.attribs = repeatString(chr(1), obj.text.length); + return obj; + }; + Changeset.gcAttributedText = function(atObj) { + // "garbage collect" the list of authors + var removedAuthors = []; + for(var a in atObj.authorMap) { + if (atObj.attribs.indexOf(chr(Number(a))) < 0) { + removedAuthors.push(atObj.authorMap[a]); + delete atObj.authorMap[a]; + } + } + return removedAuthors; + }; + Changeset.cloneAttributedText = function(emptyObj, atObj) { + var obj = emptyObj; + obj.text = atObj.text; // string + if (atObj.attribs) obj.attribs = atObj.attribs; // string + if (atObj.attribs_c) obj.attribs_c = atObj.attribs_c; // string + obj.authorMap = {}; + for(var a in atObj.authorMap) { + obj.authorMap[a] = atObj.authorMap[a]; + } + return obj; + }; + Changeset.applyToAttributedText = function(atObj, C) { + C = (C || this); + var oldText = atObj.text; + var oldAttribs = atObj.attribs; + Changeset._assert(C.isChangeset, "applyToAttributedText: 'this' is not a changeset"); + Changeset._assert(oldText.length == C.oldLen(), + "applyToAttributedText: mismatch "+oldText.length+" / "+C.oldLen()); + var textBuf = []; + var attribsBuf = []; + var authorMap = atObj.authorMap; + function authorId(author) { + for(var a in authorMap) { + if (authorMap[Number(a)] === author) { + return Number(a); + } + } + for(var i=1;i<=60000;i++) { + // don't use "in" because it's currently broken on StorableObjects + if (authorMap[i] === undefined) { + authorMap[i] = author; + return i; + } + } + } + var myBuilder = { appendNewText: function(txt, author) { + // object that acts as a "builder" in that it receives requests from + // authorSlicer to append text attributed to different authors + attribsBuf.push(repeatString(chr(authorId(author)), txt.length)); + } }; + var authorSlicer; + if (C.authors) { + authorSlicer = C.authorSlicer(myBuilder); + } + C.eachStrip(function (s, t, n) { + textBuf.push(oldText.substr(s, t), n); + attribsBuf.push(oldAttribs.substr(s, t)); + if (authorSlicer) { + authorSlicer.takeChars(n.length, n); + } + else { + myBuilder.appendNewText(n, ''); + } + }); + atObj.text = textBuf.join(''); + atObj.attribs = attribsBuf.join(''); + return atObj; + }; + Changeset.getAttributedTextCharAuthor = function(atObj, idx) { + return atObj.authorMap[ord(atObj.attribs.charAt(idx))]; + }; + Changeset.getAttributedTextCharRunLength = function(atObj, idx) { + var c = atObj.attribs.charAt(idx); + return runLength(atObj.attribs, idx, c); + }; + Changeset.eachAuthorInAttributedText = function(atObj, func) { + // call func(author, authorNum) + for(var a in atObj.authorMap) { + if (func(atObj.authorMap[a], Number(a))) break; + } + }; + Changeset.getAttributedTextAuthorByNum = function(atObj, n) { + return atObj.authorMap[n]; + }; + // Compressed attributed text can be cloned, but nothing else until uncompressed!! + Changeset.compressAttributedText = function(atObj) { + // idempotent, mutates the object, returns it + if (atObj.attribs) { + atObj.attribs_c = atObj.attribs.replace(/([\s\S])\1{0,63}/g, function(run) { + return run.charAt(0)+chr(run.length);; + }); + delete atObj.attribs; + } + return atObj; + }; + Changeset.decompressAttributedText = function(atObj) { + // idempotent, mutates the object, returns it + if (atObj.attribs_c) { + atObj.attribs = atObj.attribs_c.replace(/[\s\S][\s\S]/g, function(run) { + return repeatString(run.charAt(0), ord(run.charAt(1))); + }); + delete atObj.attribs_c; + } + return atObj; + }; +})(); diff --git a/trunk/etherpad/src/etherpad/collab/ace/easysync2.js b/trunk/etherpad/src/etherpad/collab/ace/easysync2.js new file mode 100644 index 0000000..0fa1ec4 --- /dev/null +++ b/trunk/etherpad/src/etherpad/collab/ace/easysync2.js @@ -0,0 +1,1968 @@ +// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/easysync2.js +jimport("com.etherpad.Easysync2Support"); + +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//var _opt = (this.Easysync2Support || null); +var _opt = null; // disable optimization for now + +function AttribPool() { + var p = {}; + p.numToAttrib = {}; // e.g. {0: ['foo','bar']} + p.attribToNum = {}; // e.g. {'foo,bar': 0} + p.nextNum = 0; + + p.putAttrib = function(attrib, dontAddIfAbsent) { + var str = String(attrib); + if (str in p.attribToNum) { + return p.attribToNum[str]; + } + if (dontAddIfAbsent) { + return -1; + } + var num = p.nextNum++; + p.attribToNum[str] = num; + p.numToAttrib[num] = [String(attrib[0]||''), + String(attrib[1]||'')]; + return num; + }; + + p.getAttrib = function(num) { + var pair = p.numToAttrib[num]; + if (! pair) return pair; + return [pair[0], pair[1]]; // return a mutable copy + }; + + p.getAttribKey = function(num) { + var pair = p.numToAttrib[num]; + if (! pair) return ''; + return pair[0]; + }; + + p.getAttribValue = function(num) { + var pair = p.numToAttrib[num]; + if (! pair) return ''; + return pair[1]; + }; + + p.eachAttrib = function(func) { + for(var n in p.numToAttrib) { + var pair = p.numToAttrib[n]; + func(pair[0], pair[1]); + } + }; + + p.toJsonable = function() { + return {numToAttrib: p.numToAttrib, nextNum: p.nextNum}; + }; + + p.fromJsonable = function(obj) { + p.numToAttrib = obj.numToAttrib; + p.nextNum = obj.nextNum; + p.attribToNum = {}; + for(var n in p.numToAttrib) { + p.attribToNum[String(p.numToAttrib[n])] = Number(n); + } + return p; + }; + + return p; +} + +var Changeset = {}; + +Changeset.error = function error(msg) { var e = new Error(msg); e.easysync = true; throw e; }; +Changeset.assert = function assert(b, msgParts) { + if (! b) { + var msg = Array.prototype.slice.call(arguments, 1).join(''); + Changeset.error("Changeset: "+msg); + } +}; + +Changeset.parseNum = function(str) { return parseInt(str, 36); }; +Changeset.numToString = function(num) { return num.toString(36).toLowerCase(); }; +Changeset.toBaseTen = function(cs) { + var dollarIndex = cs.indexOf('$'); + var beforeDollar = cs.substring(0, dollarIndex); + var fromDollar = cs.substring(dollarIndex); + return beforeDollar.replace(/[0-9a-z]+/g, function(s) { + return String(Changeset.parseNum(s)); }) + fromDollar; +}; + +Changeset.oldLen = function(cs) { + return Changeset.unpack(cs).oldLen; +}; +Changeset.newLen = function(cs) { + return Changeset.unpack(cs).newLen; +}; + +Changeset.opIterator = function(opsStr, optStartIndex) { + //print(opsStr); + var regex = /((?:\*[0-9a-z]+)*)(?:\|([0-9a-z]+))?([-+=])([0-9a-z]+)|\?|/g; + var startIndex = (optStartIndex || 0); + var curIndex = startIndex; + var prevIndex = curIndex; + function nextRegexMatch() { + prevIndex = curIndex; + var result; + if (_opt) { + result = _opt.nextOpInString(opsStr, curIndex); + if (result) { + if (result.opcode() == '?') { + Changeset.error("Hit error opcode in op stream"); + } + curIndex = result.lastIndex(); + } + } + else { + regex.lastIndex = curIndex; + result = regex.exec(opsStr); + curIndex = regex.lastIndex; + if (result[0] == '?') { + Changeset.error("Hit error opcode in op stream"); + } + } + return result; + } + var regexResult = nextRegexMatch(); + var obj = Changeset.newOp(); + function next(optObj) { + var op = (optObj || obj); + if (_opt && regexResult) { + op.attribs = regexResult.attribs(); + op.lines = regexResult.lines(); + op.chars = regexResult.chars(); + op.opcode = regexResult.opcode(); + regexResult = nextRegexMatch(); + } + else if ((! _opt) && regexResult[0]) { + op.attribs = regexResult[1]; + op.lines = Changeset.parseNum(regexResult[2] || 0); + op.opcode = regexResult[3]; + op.chars = Changeset.parseNum(regexResult[4]); + regexResult = nextRegexMatch(); + } + else { + Changeset.clearOp(op); + } + return op; + } + function hasNext() { return !! (_opt ? regexResult : regexResult[0]); } + function lastIndex() { return prevIndex; } + return {next: next, hasNext: hasNext, lastIndex: lastIndex}; +}; + +Changeset.clearOp = function(op) { + op.opcode = ''; + op.chars = 0; + op.lines = 0; + op.attribs = ''; +}; +Changeset.newOp = function(optOpcode) { + return {opcode:(optOpcode || ''), chars:0, lines:0, attribs:''}; +}; +Changeset.cloneOp = function(op) { + return {opcode: op.opcode, chars: op.chars, lines: op.lines, attribs: op.attribs}; +}; +Changeset.copyOp = function(op1, op2) { + op2.opcode = op1.opcode; + op2.chars = op1.chars; + op2.lines = op1.lines; + op2.attribs = op1.attribs; +}; +Changeset.opString = function(op) { + // just for debugging + if (! op.opcode) return 'null'; + var assem = Changeset.opAssembler(); + assem.append(op); + return assem.toString(); +}; +Changeset.stringOp = function(str) { + // just for debugging + return Changeset.opIterator(str).next(); +}; + +Changeset.checkRep = function(cs) { + // doesn't check things that require access to attrib pool (e.g. attribute order) + // or original string (e.g. newline positions) + var unpacked = Changeset.unpack(cs); + var oldLen = unpacked.oldLen; + var newLen = unpacked.newLen; + var ops = unpacked.ops; + var charBank = unpacked.charBank; + + var assem = Changeset.smartOpAssembler(); + var oldPos = 0; + var calcNewLen = 0; + var numInserted = 0; + var iter = Changeset.opIterator(ops); + while (iter.hasNext()) { + var o = iter.next(); + switch (o.opcode) { + case '=': oldPos += o.chars; calcNewLen += o.chars; break; + case '-': oldPos += o.chars; Changeset.assert(oldPos < oldLen, oldPos," >= ",oldLen," in ",cs); break; + case '+': { + calcNewLen += o.chars; numInserted += o.chars; + Changeset.assert(calcNewLen < newLen, calcNewLen," >= ",newLen," in ",cs); + break; + } + } + assem.append(o); + } + + calcNewLen += oldLen - oldPos; + charBank = charBank.substring(0, numInserted); + while (charBank.length < numInserted) { + charBank += "?"; + } + + assem.endDocument(); + var normalized = Changeset.pack(oldLen, calcNewLen, assem.toString(), charBank); + Changeset.assert(normalized == cs, normalized,' != ',cs); + + return cs; +} + +Changeset.smartOpAssembler = function() { + // Like opAssembler but able to produce conforming changesets + // from slightly looser input, at the cost of speed. + // Specifically: + // - merges consecutive operations that can be merged + // - strips final "=" + // - ignores 0-length changes + // - reorders consecutive + and - (which margingOpAssembler doesn't do) + + var minusAssem = Changeset.mergingOpAssembler(); + var plusAssem = Changeset.mergingOpAssembler(); + var keepAssem = Changeset.mergingOpAssembler(); + var assem = Changeset.stringAssembler(); + var lastOpcode = ''; + var lengthChange = 0; + + function flushKeeps() { + assem.append(keepAssem.toString()); + keepAssem.clear(); + } + + function flushPlusMinus() { + assem.append(minusAssem.toString()); + minusAssem.clear(); + assem.append(plusAssem.toString()); + plusAssem.clear(); + } + + function append(op) { + if (! op.opcode) return; + if (! op.chars) return; + + if (op.opcode == '-') { + if (lastOpcode == '=') { + flushKeeps(); + } + minusAssem.append(op); + lengthChange -= op.chars; + } + else if (op.opcode == '+') { + if (lastOpcode == '=') { + flushKeeps(); + } + plusAssem.append(op); + lengthChange += op.chars; + } + else if (op.opcode == '=') { + if (lastOpcode != '=') { + flushPlusMinus(); + } + keepAssem.append(op); + } + lastOpcode = op.opcode; + } + + function appendOpWithText(opcode, text, attribs, pool) { + var op = Changeset.newOp(opcode); + op.attribs = Changeset.makeAttribsString(opcode, attribs, pool); + var lastNewlinePos = text.lastIndexOf('\n'); + if (lastNewlinePos < 0) { + op.chars = text.length; + op.lines = 0; + append(op); + } + else { + op.chars = lastNewlinePos+1; + op.lines = text.match(/\n/g).length; + append(op); + op.chars = text.length - (lastNewlinePos+1); + op.lines = 0; + append(op); + } + } + + function toString() { + flushPlusMinus(); + flushKeeps(); + return assem.toString(); + } + + function clear() { + minusAssem.clear(); + plusAssem.clear(); + keepAssem.clear(); + assem.clear(); + lengthChange = 0; + } + + function endDocument() { + keepAssem.endDocument(); + } + + function getLengthChange() { + return lengthChange; + } + + return {append: append, toString: toString, clear: clear, endDocument: endDocument, + appendOpWithText: appendOpWithText, getLengthChange: getLengthChange }; +}; + +if (_opt) { + Changeset.mergingOpAssembler = function() { + var assem = _opt.mergingOpAssembler(); + + function append(op) { + assem.append(op.opcode, op.chars, op.lines, op.attribs); + } + function toString() { + return assem.toString(); + } + function clear() { + assem.clear(); + } + function endDocument() { + assem.endDocument(); + } + + return {append: append, toString: toString, clear: clear, endDocument: endDocument}; + }; +} +else { + Changeset.mergingOpAssembler = function() { + // This assembler can be used in production; it efficiently + // merges consecutive operations that are mergeable, ignores + // no-ops, and drops final pure "keeps". It does not re-order + // operations. + var assem = Changeset.opAssembler(); + var bufOp = Changeset.newOp(); + + // If we get, for example, insertions [xxx\n,yyy], those don't merge, + // but if we get [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n]. + // This variable stores the length of yyy and any other newline-less + // ops immediately after it. + var bufOpAdditionalCharsAfterNewline = 0; + + function flush(isEndDocument) { + if (bufOp.opcode) { + if (isEndDocument && bufOp.opcode == '=' && ! bufOp.attribs) { + // final merged keep, leave it implicit + } + else { + assem.append(bufOp); + if (bufOpAdditionalCharsAfterNewline) { + bufOp.chars = bufOpAdditionalCharsAfterNewline; + bufOp.lines = 0; + assem.append(bufOp); + bufOpAdditionalCharsAfterNewline = 0; + } + } + bufOp.opcode = ''; + } + } + function append(op) { + if (op.chars > 0) { + if (bufOp.opcode == op.opcode && bufOp.attribs == op.attribs) { + if (op.lines > 0) { + // bufOp and additional chars are all mergeable into a multi-line op + bufOp.chars += bufOpAdditionalCharsAfterNewline + op.chars; + bufOp.lines += op.lines; + bufOpAdditionalCharsAfterNewline = 0; + } + else if (bufOp.lines == 0) { + // both bufOp and op are in-line + bufOp.chars += op.chars; + } + else { + // append in-line text to multi-line bufOp + bufOpAdditionalCharsAfterNewline += op.chars; + } + } + else { + flush(); + Changeset.copyOp(op, bufOp); + } + } + } + function endDocument() { + flush(true); + } + function toString() { + flush(); + return assem.toString(); + } + function clear() { + assem.clear(); + Changeset.clearOp(bufOp); + } + return {append: append, toString: toString, clear: clear, endDocument: endDocument}; + }; +} + +if (_opt) { + Changeset.opAssembler = function() { + var assem = _opt.opAssembler(); + // this function allows op to be mutated later (doesn't keep a ref) + function append(op) { + assem.append(op.opcode, op.chars, op.lines, op.attribs); + } + function toString() { + return assem.toString(); + } + function clear() { + assem.clear(); + } + return {append: append, toString: toString, clear: clear}; + }; +} +else { + Changeset.opAssembler = function() { + var pieces = []; + // this function allows op to be mutated later (doesn't keep a ref) + function append(op) { + pieces.push(op.attribs); + if (op.lines) { + pieces.push('|', Changeset.numToString(op.lines)); + } + pieces.push(op.opcode); + pieces.push(Changeset.numToString(op.chars)); + } + function toString() { + return pieces.join(''); + } + function clear() { + pieces.length = 0; + } + return {append: append, toString: toString, clear: clear}; + }; +} + +Changeset.stringIterator = function(str) { + var curIndex = 0; + function assertRemaining(n) { + Changeset.assert(n <= remaining(), "!(",n," <= ",remaining(),")"); + } + function take(n) { + assertRemaining(n); + var s = str.substr(curIndex, n); + curIndex += n; + return s; + } + function peek(n) { + assertRemaining(n); + var s = str.substr(curIndex, n); + return s; + } + function skip(n) { + assertRemaining(n); + curIndex += n; + } + function remaining() { + return str.length - curIndex; + } + return {take:take, skip:skip, remaining:remaining, peek:peek}; +}; + +Changeset.stringAssembler = function() { + var pieces = []; + function append(x) { + pieces.push(String(x)); + } + function toString() { + return pieces.join(''); + } + return {append: append, toString: toString}; +}; + +// "lines" need not be an array as long as it supports certain calls (lines_foo inside). +Changeset.textLinesMutator = function(lines) { + // Mutates lines, an array of strings, in place. + // Mutation operations have the same constraints as changeset operations + // with respect to newlines, but not the other additional constraints + // (i.e. ins/del ordering, forbidden no-ops, non-mergeability, final newline). + // Can be used to mutate lists of strings where the last char of each string + // is not actually a newline, but for the purposes of N and L values, + // the caller should pretend it is, and for things to work right in that case, the input + // to insert() should be a single line with no newlines. + + var curSplice = [0,0]; + var inSplice = false; + // position in document after curSplice is applied: + var curLine = 0, curCol = 0; + // invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) && + // curLine >= curSplice[0] + // invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then + // curCol == 0 + + function lines_applySplice(s) { + lines.splice.apply(lines, s); + } + function lines_toSource() { + return lines.toSource(); + } + function lines_get(idx) { + if (lines.get) { + return lines.get(idx); + } + else { + return lines[idx]; + } + } + // can be unimplemented if removeLines's return value not needed + function lines_slice(start, end) { + if (lines.slice) { + return lines.slice(start, end); + } + else { + return []; + } + } + function lines_length() { + if ((typeof lines.length) == "number") { + return lines.length; + } + else { + return lines.length(); + } + } + + function enterSplice() { + curSplice[0] = curLine; + curSplice[1] = 0; + if (curCol > 0) { + putCurLineInSplice(); + } + inSplice = true; + } + function leaveSplice() { + lines_applySplice(curSplice); + curSplice.length = 2; + curSplice[0] = curSplice[1] = 0; + inSplice = false; + } + function isCurLineInSplice() { + return (curLine - curSplice[0] < (curSplice.length - 2)); + } + function debugPrint(typ) { + print(typ+": "+curSplice.toSource()+" / "+curLine+","+curCol+" / "+lines_toSource()); + } + function putCurLineInSplice() { + if (! isCurLineInSplice()) { + curSplice.push(lines_get(curSplice[0] + curSplice[1])); + curSplice[1]++; + } + return 2 + curLine - curSplice[0]; + } + + function skipLines(L, includeInSplice) { + if (L) { + if (includeInSplice) { + if (! inSplice) { + enterSplice(); + } + for(var i=0;i<L;i++) { + curCol = 0; + putCurLineInSplice(); + curLine++; + } + } + else { + if (inSplice) { + if (L > 1) { + leaveSplice(); + } + else { + putCurLineInSplice(); + } + } + curLine += L; + curCol = 0; + } + //print(inSplice+" / "+isCurLineInSplice()+" / "+curSplice[0]+" / "+curSplice[1]+" / "+lines.length); + /*if (inSplice && (! isCurLineInSplice()) && (curSplice[0] + curSplice[1] < lines.length)) { + print("BLAH"); + putCurLineInSplice(); + }*/ // tests case foo in remove(), which isn't otherwise covered in current impl + } + //debugPrint("skip"); + } + + function skip(N, L, includeInSplice) { + if (N) { + if (L) { + skipLines(L, includeInSplice); + } + else { + if (includeInSplice && ! inSplice) { + enterSplice(); + } + if (inSplice) { + putCurLineInSplice(); + } + curCol += N; + //debugPrint("skip"); + } + } + } + + function removeLines(L) { + var removed = ''; + if (L) { + if (! inSplice) { + enterSplice(); + } + function nextKLinesText(k) { + var m = curSplice[0] + curSplice[1]; + return lines_slice(m, m+k).join(''); + } + if (isCurLineInSplice()) { + //print(curCol); + if (curCol == 0) { + removed = curSplice[curSplice.length-1]; + // print("FOO"); // case foo + curSplice.length--; + removed += nextKLinesText(L-1); + curSplice[1] += L-1; + } + else { + removed = nextKLinesText(L-1); + curSplice[1] += L-1; + var sline = curSplice.length - 1; + removed = curSplice[sline].substring(curCol) + removed; + curSplice[sline] = curSplice[sline].substring(0, curCol) + + lines_get(curSplice[0] + curSplice[1]); + curSplice[1] += 1; + } + } + else { + removed = nextKLinesText(L); + curSplice[1] += L; + } + //debugPrint("remove"); + } + return removed; + } + + function remove(N, L) { + var removed = ''; + if (N) { + if (L) { + return removeLines(L); + } + else { + if (! inSplice) { + enterSplice(); + } + var sline = putCurLineInSplice(); + removed = curSplice[sline].substring(curCol, curCol+N); + curSplice[sline] = curSplice[sline].substring(0, curCol) + + curSplice[sline].substring(curCol+N); + //debugPrint("remove"); + } + } + return removed; + } + + function insert(text, L) { + if (text) { + if (! inSplice) { + enterSplice(); + } + if (L) { + var newLines = Changeset.splitTextLines(text); + if (isCurLineInSplice()) { + //if (curCol == 0) { + //curSplice.length--; + //curSplice[1]--; + //Array.prototype.push.apply(curSplice, newLines); + //curLine += newLines.length; + //} + //else { + var sline = curSplice.length - 1; + var theLine = curSplice[sline]; + var lineCol = curCol; + curSplice[sline] = theLine.substring(0, lineCol) + newLines[0]; + curLine++; + newLines.splice(0, 1); + Array.prototype.push.apply(curSplice, newLines); + curLine += newLines.length; + curSplice.push(theLine.substring(lineCol)); + curCol = 0; + //} + } + else { + Array.prototype.push.apply(curSplice, newLines); + curLine += newLines.length; + } + } + else { + var sline = putCurLineInSplice(); + curSplice[sline] = curSplice[sline].substring(0, curCol) + + text + curSplice[sline].substring(curCol); + curCol += text.length; + } + //debugPrint("insert"); + } + } + + function hasMore() { + //print(lines.length+" / "+inSplice+" / "+(curSplice.length - 2)+" / "+curSplice[1]); + var docLines = lines_length(); + if (inSplice) { + docLines += curSplice.length - 2 - curSplice[1]; + } + return curLine < docLines; + } + + function close() { + if (inSplice) { + leaveSplice(); + } + //debugPrint("close"); + } + + var self = {skip:skip, remove:remove, insert:insert, close:close, hasMore:hasMore, + removeLines:removeLines, skipLines: skipLines}; + return self; +}; + +Changeset.applyZip = function(in1, idx1, in2, idx2, func) { + var iter1 = Changeset.opIterator(in1, idx1); + var iter2 = Changeset.opIterator(in2, idx2); + var assem = Changeset.smartOpAssembler(); + var op1 = Changeset.newOp(); + var op2 = Changeset.newOp(); + var opOut = Changeset.newOp(); + while (op1.opcode || iter1.hasNext() || op2.opcode || iter2.hasNext()) { + if ((! op1.opcode) && iter1.hasNext()) iter1.next(op1); + if ((! op2.opcode) && iter2.hasNext()) iter2.next(op2); + func(op1, op2, opOut); + if (opOut.opcode) { + //print(opOut.toSource()); + assem.append(opOut); + opOut.opcode = ''; + } + } + assem.endDocument(); + return assem.toString(); +}; + +Changeset.unpack = function(cs) { + var headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/; + var headerMatch = headerRegex.exec(cs); + if ((! headerMatch) || (! headerMatch[0])) { + Changeset.error("Not a changeset: "+cs); + } + var oldLen = Changeset.parseNum(headerMatch[1]); + var changeSign = (headerMatch[2] == '>') ? 1 : -1; + var changeMag = Changeset.parseNum(headerMatch[3]); + var newLen = oldLen + changeSign*changeMag; + var opsStart = headerMatch[0].length; + var opsEnd = cs.indexOf("$"); + if (opsEnd < 0) opsEnd = cs.length; + return {oldLen: oldLen, newLen: newLen, ops: cs.substring(opsStart, opsEnd), + charBank: cs.substring(opsEnd+1)}; +}; + +Changeset.pack = function(oldLen, newLen, opsStr, bank) { + var lenDiff = newLen - oldLen; + var lenDiffStr = (lenDiff >= 0 ? + '>'+Changeset.numToString(lenDiff) : + '<'+Changeset.numToString(-lenDiff)); + var a = []; + a.push('Z:', Changeset.numToString(oldLen), lenDiffStr, opsStr, '$', bank); + return a.join(''); +}; + +Changeset.applyToText = function(cs, str) { + var unpacked = Changeset.unpack(cs); + Changeset.assert(str.length == unpacked.oldLen, + "mismatched apply: ",str.length," / ",unpacked.oldLen); + var csIter = Changeset.opIterator(unpacked.ops); + var bankIter = Changeset.stringIterator(unpacked.charBank); + var strIter = Changeset.stringIterator(str); + var assem = Changeset.stringAssembler(); + while (csIter.hasNext()) { + var op = csIter.next(); + switch(op.opcode) { + case '+': assem.append(bankIter.take(op.chars)); break; + case '-': strIter.skip(op.chars); break; + case '=': assem.append(strIter.take(op.chars)); break; + } + } + assem.append(strIter.take(strIter.remaining())); + return assem.toString(); +}; + +Changeset.mutateTextLines = function(cs, lines) { + var unpacked = Changeset.unpack(cs); + var csIter = Changeset.opIterator(unpacked.ops); + var bankIter = Changeset.stringIterator(unpacked.charBank); + var mut = Changeset.textLinesMutator(lines); + while (csIter.hasNext()) { + var op = csIter.next(); + switch(op.opcode) { + case '+': mut.insert(bankIter.take(op.chars), op.lines); break; + case '-': mut.remove(op.chars, op.lines); break; + case '=': mut.skip(op.chars, op.lines, (!! op.attribs)); break; + } + } + mut.close(); +}; + +Changeset.composeAttributes = function(att1, att2, resultIsMutation, pool) { + // att1 and att2 are strings like "*3*f*1c", asMutation is a boolean. + + // Sometimes attribute (key,value) pairs are treated as attribute presence + // information, while other times they are treated as operations that + // mutate a set of attributes, and this affects whether an empty value + // is a deletion or a change. + // Examples, of the form (att1Items, att2Items, resultIsMutation) -> result + // ([], [(bold, )], true) -> [(bold, )] + // ([], [(bold, )], false) -> [] + // ([], [(bold, true)], true) -> [(bold, true)] + // ([], [(bold, true)], false) -> [(bold, true)] + // ([(bold, true)], [(bold, )], true) -> [(bold, )] + // ([(bold, true)], [(bold, )], false) -> [] + + // pool can be null if att2 has no attributes. + + if ((! att1) && resultIsMutation) { + // In the case of a mutation (i.e. composing two changesets), + // an att2 composed with an empy att1 is just att2. If att1 + // is part of an attribution string, then att2 may remove + // attributes that are already gone, so don't do this optimization. + return att2; + } + if (! att2) return att1; + var atts = []; + att1.replace(/\*([0-9a-z]+)/g, function(_, a) { + atts.push(pool.getAttrib(Changeset.parseNum(a))); + return ''; + }); + att2.replace(/\*([0-9a-z]+)/g, function(_, a) { + var pair = pool.getAttrib(Changeset.parseNum(a)); + var found = false; + for(var i=0;i<atts.length;i++) { + var oldPair = atts[i]; + if (oldPair[0] == pair[0]) { + if (pair[1] || resultIsMutation) { + oldPair[1] = pair[1]; + } + else { + atts.splice(i, 1); + } + found = true; + break; + } + } + if ((! found) && (pair[1] || resultIsMutation)) { + atts.push(pair); + } + return ''; + }); + atts.sort(); + var buf = Changeset.stringAssembler(); + for(var i=0;i<atts.length;i++) { + buf.append('*'); + buf.append(Changeset.numToString(pool.putAttrib(atts[i]))); + } + //print(att1+" / "+att2+" / "+buf.toString()); + return buf.toString(); +}; + +Changeset._slicerZipperFunc = function(attOp, csOp, opOut, pool) { + // attOp is the op from the sequence that is being operated on, either an + // attribution string or the earlier of two changesets being composed. + // pool can be null if definitely not needed. + + //print(csOp.toSource()+" "+attOp.toSource()+" "+opOut.toSource()); + if (attOp.opcode == '-') { + Changeset.copyOp(attOp, opOut); + attOp.opcode = ''; + } + else if (! attOp.opcode) { + Changeset.copyOp(csOp, opOut); + csOp.opcode = ''; + } + else { + switch (csOp.opcode) { + case '-': { + if (csOp.chars <= attOp.chars) { + // delete or delete part + if (attOp.opcode == '=') { + opOut.opcode = '-'; + opOut.chars = csOp.chars; + opOut.lines = csOp.lines; + opOut.attribs = ''; + } + attOp.chars -= csOp.chars; + attOp.lines -= csOp.lines; + csOp.opcode = ''; + if (! attOp.chars) { + attOp.opcode = ''; + } + } + else { + // delete and keep going + if (attOp.opcode == '=') { + opOut.opcode = '-'; + opOut.chars = attOp.chars; + opOut.lines = attOp.lines; + opOut.attribs = ''; + } + csOp.chars -= attOp.chars; + csOp.lines -= attOp.lines; + attOp.opcode = ''; + } + break; + } + case '+': { + // insert + Changeset.copyOp(csOp, opOut); + csOp.opcode = ''; + break; + } + case '=': { + if (csOp.chars <= attOp.chars) { + // keep or keep part + opOut.opcode = attOp.opcode; + opOut.chars = csOp.chars; + opOut.lines = csOp.lines; + opOut.attribs = Changeset.composeAttributes(attOp.attribs, csOp.attribs, + attOp.opcode == '=', pool); + csOp.opcode = ''; + attOp.chars -= csOp.chars; + attOp.lines -= csOp.lines; + if (! attOp.chars) { + attOp.opcode = ''; + } + } + else { + // keep and keep going + opOut.opcode = attOp.opcode; + opOut.chars = attOp.chars; + opOut.lines = attOp.lines; + opOut.attribs = Changeset.composeAttributes(attOp.attribs, csOp.attribs, + attOp.opcode == '=', pool); + attOp.opcode = ''; + csOp.chars -= attOp.chars; + csOp.lines -= attOp.lines; + } + break; + } + case '': { + Changeset.copyOp(attOp, opOut); + attOp.opcode = ''; + break; + } + } + } +}; + +Changeset.applyToAttribution = function(cs, astr, pool) { + var unpacked = Changeset.unpack(cs); + + return Changeset.applyZip(astr, 0, unpacked.ops, 0, function(op1, op2, opOut) { + return Changeset._slicerZipperFunc(op1, op2, opOut, pool); + }); +}; + +/*Changeset.oneInsertedLineAtATimeOpIterator = function(opsStr, optStartIndex, charBank) { + var iter = Changeset.opIterator(opsStr, optStartIndex); + var bankIndex = 0; + +};*/ + +Changeset.mutateAttributionLines = function(cs, lines, pool) { + //dmesg(cs); + //dmesg(lines.toSource()+" ->"); + + var unpacked = Changeset.unpack(cs); + var csIter = Changeset.opIterator(unpacked.ops); + var csBank = unpacked.charBank; + var csBankIndex = 0; + // treat the attribution lines as text lines, mutating a line at a time + var mut = Changeset.textLinesMutator(lines); + + var lineIter = null; + function isNextMutOp() { + return (lineIter && lineIter.hasNext()) || mut.hasMore(); + } + function nextMutOp(destOp) { + if ((!(lineIter && lineIter.hasNext())) && mut.hasMore()) { + var line = mut.removeLines(1); + lineIter = Changeset.opIterator(line); + } + if (lineIter && lineIter.hasNext()) { + lineIter.next(destOp); + } + else { + destOp.opcode = ''; + } + } + var lineAssem = null; + function outputMutOp(op) { + //print("outputMutOp: "+op.toSource()); + if (! lineAssem) { + lineAssem = Changeset.mergingOpAssembler(); + } + lineAssem.append(op); + if (op.lines > 0) { + Changeset.assert(op.lines == 1, "Can't have op.lines of ",op.lines," in attribution lines"); + // ship it to the mut + mut.insert(lineAssem.toString(), 1); + lineAssem = null; + } + } + + var csOp = Changeset.newOp(); + var attOp = Changeset.newOp(); + var opOut = Changeset.newOp(); + while (csOp.opcode || csIter.hasNext() || attOp.opcode || isNextMutOp()) { + if ((! csOp.opcode) && csIter.hasNext()) { + csIter.next(csOp); + } + //print(csOp.toSource()+" "+attOp.toSource()+" "+opOut.toSource()); + //print(csOp.opcode+"/"+csOp.lines+"/"+csOp.attribs+"/"+lineAssem+"/"+lineIter+"/"+(lineIter?lineIter.hasNext():null)); + //print("csOp: "+csOp.toSource()); + if ((! csOp.opcode) && (! attOp.opcode) && + (! lineAssem) && (! (lineIter && lineIter.hasNext()))) { + break; // done + } + else if (csOp.opcode == '=' && csOp.lines > 0 && (! csOp.attribs) && (! attOp.opcode) && + (! lineAssem) && (! (lineIter && lineIter.hasNext()))) { + // skip multiple lines; this is what makes small changes not order of the document size + mut.skipLines(csOp.lines); + //print("skipped: "+csOp.lines); + csOp.opcode = ''; + } + else if (csOp.opcode == '+') { + if (csOp.lines > 1) { + var firstLineLen = csBank.indexOf('\n', csBankIndex) + 1 - csBankIndex; + Changeset.copyOp(csOp, opOut); + csOp.chars -= firstLineLen; + csOp.lines--; + opOut.lines = 1; + opOut.chars = firstLineLen; + } + else { + Changeset.copyOp(csOp, opOut); + csOp.opcode = ''; + } + outputMutOp(opOut); + csBankIndex += opOut.chars; + opOut.opcode = ''; + } + else { + if ((! attOp.opcode) && isNextMutOp()) { + nextMutOp(attOp); + } + //print("attOp: "+attOp.toSource()); + Changeset._slicerZipperFunc(attOp, csOp, opOut, pool); + if (opOut.opcode) { + outputMutOp(opOut); + opOut.opcode = ''; + } + } + } + + Changeset.assert(! lineAssem, "line assembler not finished"); + mut.close(); + + //dmesg("-> "+lines.toSource()); +}; + +Changeset.joinAttributionLines = function(theAlines) { + var assem = Changeset.mergingOpAssembler(); + for(var i=0;i<theAlines.length;i++) { + var aline = theAlines[i]; + var iter = Changeset.opIterator(aline); + while (iter.hasNext()) { + assem.append(iter.next()); + } + } + return assem.toString(); +}; + +Changeset.splitAttributionLines = function(attrOps, text) { + var iter = Changeset.opIterator(attrOps); + var assem = Changeset.mergingOpAssembler(); + var lines = []; + var pos = 0; + + function appendOp(op) { + assem.append(op); + if (op.lines > 0) { + lines.push(assem.toString()); + assem.clear(); + } + pos += op.chars; + } + + while (iter.hasNext()) { + var op = iter.next(); + var numChars = op.chars; + var numLines = op.lines; + while (numLines > 1) { + var newlineEnd = text.indexOf('\n', pos)+1; + Changeset.assert(newlineEnd > 0, "newlineEnd <= 0 in splitAttributionLines"); + op.chars = newlineEnd - pos; + op.lines = 1; + appendOp(op); + numChars -= op.chars; + numLines -= op.lines; + } + if (numLines == 1) { + op.chars = numChars; + op.lines = 1; + } + appendOp(op); + } + + return lines; +}; + +Changeset.splitTextLines = function(text) { + return text.match(/[^\n]*(?:\n|[^\n]$)/g); +}; + +Changeset.compose = function(cs1, cs2, pool) { + var unpacked1 = Changeset.unpack(cs1); + var unpacked2 = Changeset.unpack(cs2); + var len1 = unpacked1.oldLen; + var len2 = unpacked1.newLen; + Changeset.assert(len2 == unpacked2.oldLen, "mismatched composition"); + var len3 = unpacked2.newLen; + var bankIter1 = Changeset.stringIterator(unpacked1.charBank); + var bankIter2 = Changeset.stringIterator(unpacked2.charBank); + var bankAssem = Changeset.stringAssembler(); + + var newOps = Changeset.applyZip(unpacked1.ops, 0, unpacked2.ops, 0, function(op1, op2, opOut) { + //var debugBuilder = Changeset.stringAssembler(); + //debugBuilder.append(Changeset.opString(op1)); + //debugBuilder.append(','); + //debugBuilder.append(Changeset.opString(op2)); + //debugBuilder.append(' / '); + + var op1code = op1.opcode; + var op2code = op2.opcode; + if (op1code == '+' && op2code == '-') { + bankIter1.skip(Math.min(op1.chars, op2.chars)); + } + Changeset._slicerZipperFunc(op1, op2, opOut, pool); + if (opOut.opcode == '+') { + if (op2code == '+') { + bankAssem.append(bankIter2.take(opOut.chars)); + } + else { + bankAssem.append(bankIter1.take(opOut.chars)); + } + } + + //debugBuilder.append(Changeset.opString(op1)); + //debugBuilder.append(','); + //debugBuilder.append(Changeset.opString(op2)); + //debugBuilder.append(' -> '); + //debugBuilder.append(Changeset.opString(opOut)); + //print(debugBuilder.toString()); + }); + + return Changeset.pack(len1, len3, newOps, bankAssem.toString()); +}; + +Changeset.attributeTester = function(attribPair, pool) { + // returns a function that tests if a string of attributes + // (e.g. *3*4) contains a given attribute key,value that + // is already present in the pool. + if (! pool) { + return never; + } + var attribNum = pool.putAttrib(attribPair, true); + if (attribNum < 0) { + return never; + } + else { + var re = new RegExp('\\*'+Changeset.numToString(attribNum)+ + '(?!\\w)'); + return function(attribs) { + return re.test(attribs); + }; + } + function never(attribs) { return false; } +}; + +Changeset.identity = function(N) { + return Changeset.pack(N, N, "", ""); +}; + +Changeset.makeSplice = function(oldFullText, spliceStart, numRemoved, newText, optNewTextAPairs, pool) { + var oldLen = oldFullText.length; + + if (spliceStart >= oldLen) { + spliceStart = oldLen - 1; + } + if (numRemoved > oldFullText.length - spliceStart - 1) { + numRemoved = oldFullText.length - spliceStart - 1; + } + var oldText = oldFullText.substring(spliceStart, spliceStart+numRemoved); + var newLen = oldLen + newText.length - oldText.length; + + var assem = Changeset.smartOpAssembler(); + assem.appendOpWithText('=', oldFullText.substring(0, spliceStart)); + assem.appendOpWithText('-', oldText); + assem.appendOpWithText('+', newText, optNewTextAPairs, pool); + assem.endDocument(); + return Changeset.pack(oldLen, newLen, assem.toString(), newText); +}; + +Changeset.toSplices = function(cs) { + // get a list of splices, [startChar, endChar, newText] + + var unpacked = Changeset.unpack(cs); + var splices = []; + + var oldPos = 0; + var iter = Changeset.opIterator(unpacked.ops); + var charIter = Changeset.stringIterator(unpacked.charBank); + var inSplice = false; + while (iter.hasNext()) { + var op = iter.next(); + if (op.opcode == '=') { + oldPos += op.chars; + inSplice = false; + } + else { + if (! inSplice) { + splices.push([oldPos, oldPos, ""]); + inSplice = true; + } + if (op.opcode == '-') { + oldPos += op.chars; + splices[splices.length-1][1] += op.chars; + } + else if (op.opcode == '+') { + splices[splices.length-1][2] += charIter.take(op.chars); + } + } + } + + return splices; +}; + +Changeset.characterRangeFollow = function(cs, startChar, endChar, insertionsAfter) { + var newStartChar = startChar; + var newEndChar = endChar; + var splices = Changeset.toSplices(cs); + var lengthChangeSoFar = 0; + for(var i=0;i<splices.length;i++) { + var splice = splices[i]; + var spliceStart = splice[0] + lengthChangeSoFar; + var spliceEnd = splice[1] + lengthChangeSoFar; + var newTextLength = splice[2].length; + var thisLengthChange = newTextLength - (spliceEnd - spliceStart); + + if (spliceStart <= newStartChar && spliceEnd >= newEndChar) { + // splice fully replaces/deletes range + // (also case that handles insertion at a collapsed selection) + if (insertionsAfter) { + newStartChar = newEndChar = spliceStart; + } + else { + newStartChar = newEndChar = spliceStart + newTextLength; + } + } + else if (spliceEnd <= newStartChar) { + // splice is before range + newStartChar += thisLengthChange; + newEndChar += thisLengthChange; + } + else if (spliceStart >= newEndChar) { + // splice is after range + } + else if (spliceStart >= newStartChar && spliceEnd <= newEndChar) { + // splice is inside range + newEndChar += thisLengthChange; + } + else if (spliceEnd < newEndChar) { + // splice overlaps beginning of range + newStartChar = spliceStart + newTextLength; + newEndChar += thisLengthChange; + } + else { + // splice overlaps end of range + newEndChar = spliceStart; + } + + lengthChangeSoFar += thisLengthChange; + } + + return [newStartChar, newEndChar]; +}; + +Changeset.moveOpsToNewPool = function(cs, oldPool, newPool) { + // works on changeset or attribution string + var dollarPos = cs.indexOf('$'); + if (dollarPos < 0) { + dollarPos = cs.length; + } + var upToDollar = cs.substring(0, dollarPos); + var fromDollar = cs.substring(dollarPos); + // order of attribs stays the same + return upToDollar.replace(/\*([0-9a-z]+)/g, function(_, a) { + var oldNum = Changeset.parseNum(a); + var pair = oldPool.getAttrib(oldNum); + var newNum = newPool.putAttrib(pair); + return '*'+Changeset.numToString(newNum); + }) + fromDollar; +}; + +Changeset.makeAttribution = function(text) { + var assem = Changeset.smartOpAssembler(); + assem.appendOpWithText('+', text); + return assem.toString(); +}; + +// callable on a changeset, attribution string, or attribs property of an op +Changeset.eachAttribNumber = function(cs, func) { + var dollarPos = cs.indexOf('$'); + if (dollarPos < 0) { + dollarPos = cs.length; + } + var upToDollar = cs.substring(0, dollarPos); + + upToDollar.replace(/\*([0-9a-z]+)/g, function(_, a) { + func(Changeset.parseNum(a)); + return ''; + }); +}; + +// callable on a changeset, attribution string, or attribs property of an op, +// though it may easily create adjacent ops that can be merged. +Changeset.filterAttribNumbers = function(cs, filter) { + return Changeset.mapAttribNumbers(cs, filter); +}; + +Changeset.mapAttribNumbers = function(cs, func) { + var dollarPos = cs.indexOf('$'); + if (dollarPos < 0) { + dollarPos = cs.length; + } + var upToDollar = cs.substring(0, dollarPos); + + var newUpToDollar = upToDollar.replace(/\*([0-9a-z]+)/g, function(s, a) { + var n = func(Changeset.parseNum(a)); + if (n === true) { + return s; + } + else if ((typeof n) === "number") { + return '*'+Changeset.numToString(n); + } + else { + return ''; + } + }); + + return newUpToDollar + cs.substring(dollarPos); +}; + +Changeset.makeAText = function(text, attribs) { + return { text: text, attribs: (attribs || Changeset.makeAttribution(text)) }; +}; + +Changeset.applyToAText = function(cs, atext, pool) { + return { text: Changeset.applyToText(cs, atext.text), + attribs: Changeset.applyToAttribution(cs, atext.attribs, pool) }; +}; + +Changeset.cloneAText = function(atext) { + return { text: atext.text, attribs: atext.attribs }; +}; + +Changeset.copyAText = function(atext1, atext2) { + atext2.text = atext1.text; + atext2.attribs = atext1.attribs; +}; + +Changeset.appendATextToAssembler = function(atext, assem) { + // intentionally skips last newline char of atext + var iter = Changeset.opIterator(atext.attribs); + var op = Changeset.newOp(); + while (iter.hasNext()) { + iter.next(op); + if (! iter.hasNext()) { + // last op, exclude final newline + if (op.lines <= 1) { + op.lines = 0; + op.chars--; + if (op.chars) { + assem.append(op); + } + } + else { + var nextToLastNewlineEnd = + atext.text.lastIndexOf('\n', atext.text.length-2) + 1; + var lastLineLength = atext.text.length - nextToLastNewlineEnd - 1; + op.lines--; + op.chars -= (lastLineLength + 1); + assem.append(op); + op.lines = 0; + op.chars = lastLineLength; + if (op.chars) { + assem.append(op); + } + } + } + else { + assem.append(op); + } + } +}; + +Changeset.prepareForWire = function(cs, pool) { + var newPool = new AttribPool(); + var newCs = Changeset.moveOpsToNewPool(cs, pool, newPool); + return {translated: newCs, pool: newPool}; +}; + +Changeset.isIdentity = function(cs) { + var unpacked = Changeset.unpack(cs); + return unpacked.ops == "" && unpacked.oldLen == unpacked.newLen; +}; + +Changeset.opAttributeValue = function(op, key, pool) { + return Changeset.attribsAttributeValue(op.attribs, key, pool); +}; + +Changeset.attribsAttributeValue = function(attribs, key, pool) { + var value = ''; + if (attribs) { + Changeset.eachAttribNumber(attribs, function(n) { + if (pool.getAttribKey(n) == key) { + value = pool.getAttribValue(n); + } + }); + } + return value; +}; + +Changeset.builder = function(oldLen) { + var assem = Changeset.smartOpAssembler(); + var o = Changeset.newOp(); + var charBank = Changeset.stringAssembler(); + + var self = { + // attribs are [[key1,value1],[key2,value2],...] or '*0*1...' (no pool needed in latter case) + keep: function(N, L, attribs, pool) { + o.opcode = '='; + o.attribs = (attribs && + Changeset.makeAttribsString('=', attribs, pool)) || ''; + o.chars = N; + o.lines = (L || 0); + assem.append(o); + return self; + }, + keepText: function(text, attribs, pool) { + assem.appendOpWithText('=', text, attribs, pool); + return self; + }, + insert: function(text, attribs, pool) { + assem.appendOpWithText('+', text, attribs, pool); + charBank.append(text); + return self; + }, + remove: function(N, L) { + o.opcode = '-'; + o.attribs = ''; + o.chars = N; + o.lines = (L || 0); + assem.append(o); + return self; + }, + toString: function() { + assem.endDocument(); + var newLen = oldLen + assem.getLengthChange(); + return Changeset.pack(oldLen, newLen, assem.toString(), + charBank.toString()); + } + }; + + return self; +}; + +Changeset.makeAttribsString = function(opcode, attribs, pool) { + // makeAttribsString(opcode, '*3') or makeAttribsString(opcode, [['foo','bar']], myPool) work + if (! attribs) { + return ''; + } + else if ((typeof attribs) == "string") { + return attribs; + } + else if (pool && attribs && attribs.length) { + if (attribs.length > 1) { + attribs = attribs.slice(); + attribs.sort(); + } + var result = []; + for(var i=0;i<attribs.length;i++) { + var pair = attribs[i]; + if (opcode == '=' || (opcode == '+' && pair[1])) { + result.push('*'+Changeset.numToString(pool.putAttrib(pair))); + } + } + return result.join(''); + } +}; + +// like "substring" but on a single-line attribution string +Changeset.subattribution = function(astr, start, optEnd) { + var iter = Changeset.opIterator(astr, 0); + var assem = Changeset.smartOpAssembler(); + var attOp = Changeset.newOp(); + var csOp = Changeset.newOp(); + var opOut = Changeset.newOp(); + + function doCsOp() { + if (csOp.chars) { + while (csOp.opcode && (attOp.opcode || iter.hasNext())) { + if (! attOp.opcode) iter.next(attOp); + + if (csOp.opcode && attOp.opcode && csOp.chars >= attOp.chars && + attOp.lines > 0 && csOp.lines <= 0) { + csOp.lines++; + } + + Changeset._slicerZipperFunc(attOp, csOp, opOut, null); + if (opOut.opcode) { + assem.append(opOut); + opOut.opcode = ''; + } + } + } + } + + csOp.opcode = '-'; + csOp.chars = start; + + doCsOp(); + + if (optEnd === undefined) { + if (attOp.opcode) { + assem.append(attOp); + } + while (iter.hasNext()) { + iter.next(attOp); + assem.append(attOp); + } + } + else { + csOp.opcode = '='; + csOp.chars = optEnd - start; + doCsOp(); + } + + return assem.toString(); +}; + +Changeset.inverse = function(cs, lines, alines, pool) { + // lines and alines are what the changeset is meant to apply to. + // They may be arrays or objects with .get(i) and .length methods. + // They include final newlines on lines. + function lines_get(idx) { + if (lines.get) { + return lines.get(idx); + } + else { + return lines[idx]; + } + } + function lines_length() { + if ((typeof lines.length) == "number") { + return lines.length; + } + else { + return lines.length(); + } + } + function alines_get(idx) { + if (alines.get) { + return alines.get(idx); + } + else { + return alines[idx]; + } + } + function alines_length() { + if ((typeof alines.length) == "number") { + return alines.length; + } + else { + return alines.length(); + } + } + + var curLine = 0; + var curChar = 0; + var curLineOpIter = null; + var curLineOpIterLine; + var curLineNextOp = Changeset.newOp('+'); + + var unpacked = Changeset.unpack(cs); + var csIter = Changeset.opIterator(unpacked.ops); + var builder = Changeset.builder(unpacked.newLen); + + function consumeAttribRuns(numChars, func/*(len, attribs, endsLine)*/) { + + if ((! curLineOpIter) || (curLineOpIterLine != curLine)) { + // create curLineOpIter and advance it to curChar + curLineOpIter = Changeset.opIterator(alines_get(curLine)); + curLineOpIterLine = curLine; + var indexIntoLine = 0; + var done = false; + while (! done) { + curLineOpIter.next(curLineNextOp); + if (indexIntoLine + curLineNextOp.chars >= curChar) { + curLineNextOp.chars -= (curChar - indexIntoLine); + done = true; + } + else { + indexIntoLine += curLineNextOp.chars; + } + } + } + + while (numChars > 0) { + if ((! curLineNextOp.chars) && (! curLineOpIter.hasNext())) { + curLine++; + curChar = 0; + curLineOpIterLine = curLine; + curLineNextOp.chars = 0; + curLineOpIter = Changeset.opIterator(alines_get(curLine)); + } + if (! curLineNextOp.chars) { + curLineOpIter.next(curLineNextOp); + } + var charsToUse = Math.min(numChars, curLineNextOp.chars); + func(charsToUse, curLineNextOp.attribs, + charsToUse == curLineNextOp.chars && curLineNextOp.lines > 0); + numChars -= charsToUse; + curLineNextOp.chars -= charsToUse; + curChar += charsToUse; + } + + if ((! curLineNextOp.chars) && (! curLineOpIter.hasNext())) { + curLine++; + curChar = 0; + } + } + + function skip(N, L) { + if (L) { + curLine += L; + curChar = 0; + } + else { + if (curLineOpIter && curLineOpIterLine == curLine) { + consumeAttribRuns(N, function() {}); + } + else { + curChar += N; + } + } + } + + function nextText(numChars) { + var len = 0; + var assem = Changeset.stringAssembler(); + var firstString = lines_get(curLine).substring(curChar); + len += firstString.length; + assem.append(firstString); + + var lineNum = curLine+1; + while (len < numChars) { + var nextString = lines_get(lineNum); + len += nextString.length; + assem.append(nextString); + lineNum++; + } + + return assem.toString().substring(0, numChars); + } + + function cachedStrFunc(func) { + var cache = {}; + return function(s) { + if (! cache[s]) { + cache[s] = func(s); + } + return cache[s]; + }; + } + + var attribKeys = []; + var attribValues = []; + while (csIter.hasNext()) { + var csOp = csIter.next(); + if (csOp.opcode == '=') { + if (csOp.attribs) { + attribKeys.length = 0; + attribValues.length = 0; + Changeset.eachAttribNumber(csOp.attribs, function(n) { + attribKeys.push(pool.getAttribKey(n)); + attribValues.push(pool.getAttribValue(n)); + }); + var undoBackToAttribs = cachedStrFunc(function(attribs) { + var backAttribs = []; + for(var i=0;i<attribKeys.length;i++) { + var appliedKey = attribKeys[i]; + var appliedValue = attribValues[i]; + var oldValue = Changeset.attribsAttributeValue(attribs, appliedKey, pool); + if (appliedValue != oldValue) { + backAttribs.push([appliedKey, oldValue]); + } + } + return Changeset.makeAttribsString('=', backAttribs, pool); + }); + consumeAttribRuns(csOp.chars, function(len, attribs, endsLine) { + builder.keep(len, endsLine ? 1 : 0, undoBackToAttribs(attribs)); + }); + } + else { + skip(csOp.chars, csOp.lines); + builder.keep(csOp.chars, csOp.lines); + } + } + else if (csOp.opcode == '+') { + builder.remove(csOp.chars, csOp.lines); + } + else if (csOp.opcode == '-') { + var textBank = nextText(csOp.chars); + var textBankIndex = 0; + consumeAttribRuns(csOp.chars, function(len, attribs, endsLine) { + builder.insert(textBank.substr(textBankIndex, len), attribs); + textBankIndex += len; + }); + } + } + + return Changeset.checkRep(builder.toString()); +}; + +// %CLIENT FILE ENDS HERE% + +Changeset.follow = function(cs1, cs2, reverseInsertOrder, pool) { + var unpacked1 = Changeset.unpack(cs1); + var unpacked2 = Changeset.unpack(cs2); + var len1 = unpacked1.oldLen; + var len2 = unpacked2.oldLen; + Changeset.assert(len1 == len2, "mismatched follow"); + var chars1 = Changeset.stringIterator(unpacked1.charBank); + var chars2 = Changeset.stringIterator(unpacked2.charBank); + + var oldLen = unpacked1.newLen; + var oldPos = 0; + var newLen = 0; + + var hasInsertFirst = Changeset.attributeTester(['insertorder','first'], + pool); + + var newOps = Changeset.applyZip(unpacked1.ops, 0, unpacked2.ops, 0, function(op1, op2, opOut) { + if (op1.opcode == '+' || op2.opcode == '+') { + var whichToDo; + if (op2.opcode != '+') { + whichToDo = 1; + } + else if (op1.opcode != '+') { + whichToDo = 2; + } + else { + // both + + var firstChar1 = chars1.peek(1); + var firstChar2 = chars2.peek(1); + var insertFirst1 = hasInsertFirst(op1.attribs); + var insertFirst2 = hasInsertFirst(op2.attribs); + if (insertFirst1 && ! insertFirst2) { + whichToDo = 1; + } + else if (insertFirst2 && ! insertFirst1) { + whichToDo = 2; + } + // insert string that doesn't start with a newline first so as not to break up lines + else if (firstChar1 == '\n' && firstChar2 != '\n') { + whichToDo = 2; + } + else if (firstChar1 != '\n' && firstChar2 == '\n') { + whichToDo = 1; + } + // break symmetry: + else if (reverseInsertOrder) { + whichToDo = 2; + } + else { + whichToDo = 1; + } + } + if (whichToDo == 1) { + chars1.skip(op1.chars); + opOut.opcode = '='; + opOut.lines = op1.lines; + opOut.chars = op1.chars; + opOut.attribs = ''; + op1.opcode = ''; + } + else { + // whichToDo == 2 + chars2.skip(op2.chars); + Changeset.copyOp(op2, opOut); + op2.opcode = ''; + } + } + else if (op1.opcode == '-') { + if (! op2.opcode) { + op1.opcode = ''; + } + else { + if (op1.chars <= op2.chars) { + op2.chars -= op1.chars; + op2.lines -= op1.lines; + op1.opcode = ''; + if (! op2.chars) { + op2.opcode = ''; + } + } + else { + op1.chars -= op2.chars; + op1.lines -= op2.lines; + op2.opcode = ''; + } + } + } + else if (op2.opcode == '-') { + Changeset.copyOp(op2, opOut); + if (! op1.opcode) { + op2.opcode = ''; + } + else if (op2.chars <= op1.chars) { + // delete part or all of a keep + op1.chars -= op2.chars; + op1.lines -= op2.lines; + op2.opcode = ''; + if (! op1.chars) { + op1.opcode = ''; + } + } + else { + // delete all of a keep, and keep going + opOut.lines = op1.lines; + opOut.chars = op1.chars; + op2.lines -= op1.lines; + op2.chars -= op1.chars; + op1.opcode = ''; + } + } + else if (! op1.opcode) { + Changeset.copyOp(op2, opOut); + op2.opcode = ''; + } + else if (! op2.opcode) { + Changeset.copyOp(op1, opOut); + op1.opcode = ''; + } + else { + // both keeps + opOut.opcode = '='; + opOut.attribs = Changeset.followAttributes(op1.attribs, op2.attribs, pool); + if (op1.chars <= op2.chars) { + opOut.chars = op1.chars; + opOut.lines = op1.lines; + op2.chars -= op1.chars; + op2.lines -= op1.lines; + op1.opcode = ''; + if (! op2.chars) { + op2.opcode = ''; + } + } + else { + opOut.chars = op2.chars; + opOut.lines = op2.lines; + op1.chars -= op2.chars; + op1.lines -= op2.lines; + op2.opcode = ''; + } + } + switch (opOut.opcode) { + case '=': oldPos += opOut.chars; newLen += opOut.chars; break; + case '-': oldPos += opOut.chars; break; + case '+': newLen += opOut.chars; break; + } + }); + newLen += oldLen - oldPos; + + return Changeset.pack(oldLen, newLen, newOps, unpacked2.charBank); +}; + +Changeset.followAttributes = function(att1, att2, pool) { + // The merge of two sets of attribute changes to the same text + // takes the lexically-earlier value if there are two values + // for the same key. Otherwise, all key/value changes from + // both attribute sets are taken. This operation is the "follow", + // so a set of changes is produced that can be applied to att1 + // to produce the merged set. + if ((! att2) || (! pool)) return ''; + if (! att1) return att2; + var atts = []; + att2.replace(/\*([0-9a-z]+)/g, function(_, a) { + atts.push(pool.getAttrib(Changeset.parseNum(a))); + return ''; + }); + att1.replace(/\*([0-9a-z]+)/g, function(_, a) { + var pair1 = pool.getAttrib(Changeset.parseNum(a)); + for(var i=0;i<atts.length;i++) { + var pair2 = atts[i]; + if (pair1[0] == pair2[0]) { + if (pair1[1] <= pair2[1]) { + // winner of merge is pair1, delete this attribute + atts.splice(i, 1); + } + break; + } + } + return ''; + }); + // we've only removed attributes, so they're already sorted + var buf = Changeset.stringAssembler(); + for(var i=0;i<atts.length;i++) { + buf.append('*'); + buf.append(Changeset.numToString(pool.putAttrib(atts[i]))); + } + return buf.toString(); +}; diff --git a/trunk/etherpad/src/etherpad/collab/ace/easysync2_tests.js b/trunk/etherpad/src/etherpad/collab/ace/easysync2_tests.js new file mode 100644 index 0000000..7a23dc0 --- /dev/null +++ b/trunk/etherpad/src/etherpad/collab/ace/easysync2_tests.js @@ -0,0 +1,877 @@ +// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/easysync2_tests.js +import("etherpad.collab.ace.easysync2.*") + +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +function runTests() { + + function print(str) { + java.lang.System.out.println(str); + } + + function assert(code, optMsg) { + if (! eval(code)) throw new Error("FALSE: "+(optMsg || code)); + } + function literal(v) { + if ((typeof v) == "string") { + return '"'+v.replace(/[\\\"]/g, '\\$1').replace(/\n/g, '\\n')+'"'; + } + else return v.toSource(); + } + function assertEqualArrays(a, b) { + assert(literal(a)+".toSource() == "+literal(b)+".toSource()"); + } + function assertEqualStrings(a, b) { + assert(literal(a)+" == "+literal(b)); + } + + function throughIterator(opsStr) { + var iter = Changeset.opIterator(opsStr); + var assem = Changeset.opAssembler(); + while (iter.hasNext()) { + assem.append(iter.next()); + } + return assem.toString(); + } + + function throughSmartAssembler(opsStr) { + var iter = Changeset.opIterator(opsStr); + var assem = Changeset.smartOpAssembler(); + while (iter.hasNext()) { + assem.append(iter.next()); + } + assem.endDocument(); + return assem.toString(); + } + + (function() { + print("> throughIterator"); + var x = '-c*3*4+6|3=az*asdf0*1*2*3+1=1-1+1*0+1=1-1+1|c=c-1'; + assert("throughIterator("+literal(x)+") == "+literal(x)); + })(); + + (function() { + print("> throughSmartAssembler"); + var x = '-c*3*4+6|3=az*asdf0*1*2*3+1=1-1+1*0+1=1-1+1|c=c-1'; + assert("throughSmartAssembler("+literal(x)+") == "+literal(x)); + })(); + + function applyMutations(mu, arrayOfArrays) { + arrayOfArrays.forEach(function (a) { + var result = mu[a[0]].apply(mu, a.slice(1)); + if (a[0] == 'remove' && a[3]) { + assertEqualStrings(a[3], result); + } + }); + } + + function mutationsToChangeset(oldLen, arrayOfArrays) { + var assem = Changeset.smartOpAssembler(); + var op = Changeset.newOp(); + var bank = Changeset.stringAssembler(); + var oldPos = 0; + var newLen = 0; + arrayOfArrays.forEach(function (a) { + if (a[0] == 'skip') { + op.opcode = '='; + op.chars = a[1]; + op.lines = (a[2] || 0); + assem.append(op); + oldPos += op.chars; + newLen += op.chars; + } + else if (a[0] == 'remove') { + op.opcode = '-'; + op.chars = a[1]; + op.lines = (a[2] || 0); + assem.append(op); + oldPos += op.chars; + } + else if (a[0] == 'insert') { + op.opcode = '+'; + bank.append(a[1]); + op.chars = a[1].length; + op.lines = (a[2] || 0); + assem.append(op); + newLen += op.chars; + } + }); + newLen += oldLen - oldPos; + assem.endDocument(); + return Changeset.pack(oldLen, newLen, assem.toString(), + bank.toString()); + } + + function runMutationTest(testId, origLines, muts, correct) { + print("> runMutationTest#"+testId); + var lines = origLines.slice(); + var mu = Changeset.textLinesMutator(lines); + applyMutations(mu, muts); + mu.close(); + assertEqualArrays(correct, lines); + + var inText = origLines.join(''); + var cs = mutationsToChangeset(inText.length, muts); + lines = origLines.slice(); + Changeset.mutateTextLines(cs, lines); + assertEqualArrays(correct, lines); + + var correctText = correct.join(''); + //print(literal(cs)); + var outText = Changeset.applyToText(cs, inText); + assertEqualStrings(correctText, outText); + } + + runMutationTest(1, ["apple\n", "banana\n", "cabbage\n", "duffle\n", "eggplant\n"], + [['remove',1,0,"a"],['insert',"tu"],['remove',1,0,"p"],['skip',4,1],['skip',7,1], + ['insert',"cream\npie\n",2],['skip',2],['insert',"bot"],['insert',"\n",1], + ['insert',"bu"],['skip',3],['remove',3,1,"ge\n"],['remove',6,0,"duffle"]], + ["tuple\n","banana\n","cream\n","pie\n", "cabot\n","bubba\n","eggplant\n"]); + + runMutationTest(2, ["apple\n", "banana\n", "cabbage\n", "duffle\n", "eggplant\n"], + [['remove',1,0,"a"],['remove',1,0,"p"],['insert',"tu"],['skip',11,2], + ['insert',"cream\npie\n",2],['skip',2],['insert',"bot"],['insert',"\n",1], + ['insert',"bu"],['skip',3],['remove',3,1,"ge\n"],['remove',6,0,"duffle"]], + ["tuple\n","banana\n","cream\n","pie\n", "cabot\n","bubba\n","eggplant\n"]); + + runMutationTest(3, ["apple\n", "banana\n", "cabbage\n", "duffle\n", "eggplant\n"], + [['remove',6,1,"apple\n"],['skip',15,2],['skip',6],['remove',1,1,"\n"], + ['remove',8,0,"eggplant"],['skip',1,1]], + ["banana\n","cabbage\n","duffle\n"]); + + runMutationTest(4, ["15\n"], + [['skip',1],['insert',"\n2\n3\n4\n",4],['skip',2,1]], + ["1\n","2\n","3\n","4\n","5\n"]); + + runMutationTest(5, ["1\n","2\n","3\n","4\n","5\n"], + [['skip',1],['remove',7,4,"\n2\n3\n4\n"],['skip',2,1]], + ["15\n"]); + + runMutationTest(6, ["123\n","abc\n","def\n","ghi\n","xyz\n"], + [['insert',"0"],['skip',4,1],['skip',4,1],['remove',8,2,"def\nghi\n"],['skip',4,1]], + ["0123\n", "abc\n", "xyz\n"]); + + runMutationTest(7, ["apple\n", "banana\n", "cabbage\n", "duffle\n", "eggplant\n"], + [['remove',6,1,"apple\n"],['skip',15,2,true],['skip',6,0,true],['remove',1,1,"\n"], + ['remove',8,0,"eggplant"],['skip',1,1,true]], + ["banana\n","cabbage\n","duffle\n"]); + + function poolOrArray(attribs) { + if (attribs.getAttrib) { + return attribs; // it's already an attrib pool + } + else { + // assume it's an array of attrib strings to be split and added + var p = new AttribPool(); + attribs.forEach(function (kv) { p.putAttrib(kv.split(',')); }); + return p; + } + } + + function runApplyToAttributionTest(testId, attribs, cs, inAttr, outCorrect) { + print("> applyToAttribution#"+testId); + var p = poolOrArray(attribs); + var result = Changeset.applyToAttribution( + Changeset.checkRep(cs), inAttr, p); + assertEqualStrings(outCorrect, result); + } + + // turn c<b>a</b>ctus\n into a<b>c</b>tusabcd\n + runApplyToAttributionTest(1, ['bold,', 'bold,true'], + "Z:7>3-1*0=1*1=1=3+4$abcd", + "+1*1+1|1+5", "+1*1+1|1+8"); + + // turn "david\ngreenspan\n" into "<b>david\ngreen</b>\n" + runApplyToAttributionTest(2, ['bold,', 'bold,true'], + "Z:g<4*1|1=6*1=5-4$", + "|2+g", "*1|1+6*1+5|1+1"); + + (function() { + print("> mutatorHasMore"); + var lines = ["1\n", "2\n", "3\n", "4\n"]; + var mu; + + mu = Changeset.textLinesMutator(lines); + assert(mu.hasMore()+' == true'); + mu.skip(8,4); + assert(mu.hasMore()+' == false'); + mu.close(); + assert(mu.hasMore()+' == false'); + + // still 1,2,3,4 + mu = Changeset.textLinesMutator(lines); + assert(mu.hasMore()+' == true'); + mu.remove(2,1); + assert(mu.hasMore()+' == true'); + mu.skip(2,1); + assert(mu.hasMore()+' == true'); + mu.skip(2,1); + assert(mu.hasMore()+' == true'); + mu.skip(2,1); + assert(mu.hasMore()+' == false'); + mu.insert("5\n", 1); + assert(mu.hasMore()+' == false'); + mu.close(); + assert(mu.hasMore()+' == false'); + + // 2,3,4,5 now + mu = Changeset.textLinesMutator(lines); + assert(mu.hasMore()+' == true'); + mu.remove(6,3); + assert(mu.hasMore()+' == true'); + mu.remove(2,1); + assert(mu.hasMore()+' == false'); + mu.insert("hello\n", 1); + assert(mu.hasMore()+' == false'); + mu.close(); + assert(mu.hasMore()+' == false'); + + })(); + + function runMutateAttributionTest(testId, attribs, cs, alines, outCorrect) { + print("> runMutateAttributionTest#"+testId); + var p = poolOrArray(attribs); + var alines2 = Array.prototype.slice.call(alines); + var result = Changeset.mutateAttributionLines( + Changeset.checkRep(cs), alines2, p); + assertEqualArrays(outCorrect, alines2); + + print("> runMutateAttributionTest#"+testId+".applyToAttribution"); + function removeQuestionMarks(a) { return a.replace(/\?/g, ''); } + var inMerged = Changeset.joinAttributionLines(alines.map(removeQuestionMarks)); + var correctMerged = Changeset.joinAttributionLines(outCorrect.map(removeQuestionMarks)); + var mergedResult = Changeset.applyToAttribution(cs, inMerged, p); + assertEqualStrings(correctMerged, mergedResult); + } + + // turn 123\n 456\n 789\n into 123\n 4<b>5</b>6\n 789\n + runMutateAttributionTest(1, ["bold,true"], "Z:c>0|1=4=1*0=1$", ["|1+4", "|1+4", "|1+4"], + ["|1+4", "+1*0+1|1+2", "|1+4"]); + + // make a document bold + runMutateAttributionTest(2, ["bold,true"], "Z:c>0*0|3=c$", ["|1+4", "|1+4", "|1+4"], + ["*0|1+4", "*0|1+4", "*0|1+4"]); + + // clear bold on document + runMutateAttributionTest(3, ["bold,","bold,true"], "Z:c>0*0|3=c$", + ["*1+1+1*1+1|1+1", "+1*1+1|1+2", "*1+1+1*1+1|1+1"], + ["|1+4", "|1+4", "|1+4"]); + + // add a character on line 3 of a document with 5 blank lines, and make sure + // the optimization that skips purely-kept lines is working; if any attribution string + // with a '?' is parsed it will cause an error. + runMutateAttributionTest(4, ['foo,bar','line,1','line,2','line,3','line,4','line,5'], + "Z:5>1|2=2+1$x", + ["?*1|1+1", "?*2|1+1", "*3|1+1", "?*4|1+1", "?*5|1+1"], + ["?*1|1+1", "?*2|1+1", "+1*3|1+1", "?*4|1+1", "?*5|1+1"]); + + var testPoolWithChars = (function() { + var p = new AttribPool(); + p.putAttrib(['char','newline']); + for(var i=1;i<36;i++) { + p.putAttrib(['char',Changeset.numToString(i)]); + } + p.putAttrib(['char','']); + return p; + })(); + + // based on runMutationTest#1 + runMutateAttributionTest(5, testPoolWithChars, + "Z:11>7-2*t+1*u+1|2=b|2+a=2*b+1*o+1*t+1*0|1+1*b+1*u+1=3|1-3-6$"+ + "tucream\npie\nbot\nbu", + ["*a+1*p+2*l+1*e+1*0|1+1", + "*b+1*a+1*n+1*a+1*n+1*a+1*0|1+1", + "*c+1*a+1*b+2*a+1*g+1*e+1*0|1+1", + "*d+1*u+1*f+2*l+1*e+1*0|1+1", + "*e+1*g+2*p+1*l+1*a+1*n+1*t+1*0|1+1"], + ["*t+1*u+1*p+1*l+1*e+1*0|1+1", + "*b+1*a+1*n+1*a+1*n+1*a+1*0|1+1", + "|1+6", + "|1+4", + "*c+1*a+1*b+1*o+1*t+1*0|1+1", + "*b+1*u+1*b+2*a+1*0|1+1", + "*e+1*g+2*p+1*l+1*a+1*n+1*t+1*0|1+1"]); + + // based on runMutationTest#3 + runMutateAttributionTest(6, testPoolWithChars, + "Z:11<f|1-6|2=f=6|1-1-8$", + ["*a|1+6", "*b|1+7", "*c|1+8", "*d|1+7", "*e|1+9"], + ["*b|1+7", "*c|1+8", "*d+6*e|1+1"]); + + // based on runMutationTest#4 + runMutateAttributionTest(7, testPoolWithChars, + "Z:3>7=1|4+7$\n2\n3\n4\n", + ["*1+1*5|1+2"], + ["*1+1|1+1","|1+2","|1+2","|1+2","*5|1+2"]); + + // based on runMutationTest#5 + runMutateAttributionTest(8, testPoolWithChars, + "Z:a<7=1|4-7$", + ["*1|1+2","*2|1+2","*3|1+2","*4|1+2","*5|1+2"], + ["*1+1*5|1+2"]); + + // based on runMutationTest#6 + runMutateAttributionTest(9, testPoolWithChars, + "Z:k<7*0+1*10|2=8|2-8$0", + ["*1+1*2+1*3+1|1+1","*a+1*b+1*c+1|1+1", + "*d+1*e+1*f+1|1+1","*g+1*h+1*i+1|1+1","?*x+1*y+1*z+1|1+1"], + ["*0+1|1+4", "|1+4", "?*x+1*y+1*z+1|1+1"]); + + runMutateAttributionTest(10, testPoolWithChars, + "Z:6>4=1+1=1+1|1=1+1=1*0+1$abcd", + ["|1+3", "|1+3"], + ["|1+5", "+2*0+1|1+2"]); + + + runMutateAttributionTest(11, testPoolWithChars, + "Z:s>1|1=4=6|1+1$\n", + ["*0|1+4", "*0|1+8", "*0+5|1+1", "*0|1+1", "*0|1+5", "*0|1+1", "*0|1+1", "*0|1+1", "|1+1"], + ["*0|1+4", "*0+6|1+1", "*0|1+2", "*0+5|1+1", "*0|1+1", "*0|1+5", "*0|1+1", "*0|1+1", "*0|1+1", "|1+1"]); + + function randomInlineString(len, rand) { + var assem = Changeset.stringAssembler(); + for(var i=0;i<len;i++) { + assem.append(String.fromCharCode(rand.nextInt(26) + 97)); + } + return assem.toString(); + } + + function randomMultiline(approxMaxLines, approxMaxCols, rand) { + var numParts = rand.nextInt(approxMaxLines*2)+1; + var txt = Changeset.stringAssembler(); + txt.append(rand.nextInt(2) ? '\n' : ''); + for(var i=0;i<numParts;i++) { + if ((i % 2) == 0) { + if (rand.nextInt(10)) { + txt.append(randomInlineString(rand.nextInt(approxMaxCols)+1, rand)); + } + else { + txt.append('\n'); + } + } + else { + txt.append('\n'); + } + } + return txt.toString(); + } + + function randomStringOperation(numCharsLeft, rand) { + var result; + switch(rand.nextInt(9)) { + case 0: { + // insert char + result = {insert: randomInlineString(1, rand)}; + break; + } + case 1: { + // delete char + result = {remove: 1}; + break; + } + case 2: { + // skip char + result = {skip: 1}; + break; + } + case 3: { + // insert small + result = {insert: randomInlineString(rand.nextInt(4)+1, rand)}; + break; + } + case 4: { + // delete small + result = {remove: rand.nextInt(4)+1}; + break; + } + case 5: { + // skip small + result = {skip: rand.nextInt(4)+1}; + break; + } + case 6: { + // insert multiline; + result = {insert: randomMultiline(5, 20, rand)}; + break; + } + case 7: { + // delete multiline + result = {remove: Math.round(numCharsLeft * rand.nextDouble() * rand.nextDouble()) }; + break; + } + case 8: { + // skip multiline + result = {skip: Math.round(numCharsLeft * rand.nextDouble() * rand.nextDouble()) }; + break; + } + case 9: { + // delete to end + result = {remove: numCharsLeft}; + break; + } + case 10: { + // skip to end + result = {skip: numCharsLeft}; + break; + } + } + var maxOrig = numCharsLeft - 1; + if ('remove' in result) { + result.remove = Math.min(result.remove, maxOrig); + } + else if ('skip' in result) { + result.skip = Math.min(result.skip, maxOrig); + } + return result; + } + + function randomTwoPropAttribs(opcode, rand) { + // assumes attrib pool like ['apple,','apple,true','banana,','banana,true'] + if (opcode == '-' || rand.nextInt(3)) { + return ''; + } + else if (rand.nextInt(3)) { + if (opcode == '+' || rand.nextInt(2)) { + return '*'+Changeset.numToString(rand.nextInt(2)*2+1); + } + else { + return '*'+Changeset.numToString(rand.nextInt(2)*2); + } + } + else { + if (opcode == '+' || rand.nextInt(4) == 0) { + return '*1*3'; + } + else { + return ['*0*2', '*0*3', '*1*2'][rand.nextInt(3)]; + } + } + } + + function randomTestChangeset(origText, rand, withAttribs) { + var charBank = Changeset.stringAssembler(); + var textLeft = origText; // always keep final newline + var outTextAssem = Changeset.stringAssembler(); + var opAssem = Changeset.smartOpAssembler(); + var oldLen = origText.length; + + var nextOp = Changeset.newOp(); + function appendMultilineOp(opcode, txt) { + nextOp.opcode = opcode; + if (withAttribs) { + nextOp.attribs = randomTwoPropAttribs(opcode, rand); + } + txt.replace(/\n|[^\n]+/g, function (t) { + if (t == '\n') { + nextOp.chars = 1; + nextOp.lines = 1; + opAssem.append(nextOp); + } + else { + nextOp.chars = t.length; + nextOp.lines = 0; + opAssem.append(nextOp); + } + return ''; + }); + } + + function doOp() { + var o = randomStringOperation(textLeft.length, rand); + if (o.insert) { + var txt = o.insert; + charBank.append(txt); + outTextAssem.append(txt); + appendMultilineOp('+', txt); + } + else if (o.skip) { + var txt = textLeft.substring(0, o.skip); + textLeft = textLeft.substring(o.skip); + outTextAssem.append(txt); + appendMultilineOp('=', txt); + } + else if (o.remove) { + var txt = textLeft.substring(0, o.remove); + textLeft = textLeft.substring(o.remove); + appendMultilineOp('-', txt); + } + } + + while (textLeft.length > 1) doOp(); + for(var i=0;i<5;i++) doOp(); // do some more (only insertions will happen) + + var outText = outTextAssem.toString()+'\n'; + opAssem.endDocument(); + var cs = Changeset.pack(oldLen, outText.length, opAssem.toString(), charBank.toString()); + Changeset.checkRep(cs); + return [cs, outText]; + } + + function testCompose(randomSeed) { + var rand = new java.util.Random(randomSeed); + print("> testCompose#"+randomSeed); + + var p = new AttribPool(); + + var startText = randomMultiline(10, 20, rand)+'\n'; + + var x1 = randomTestChangeset(startText, rand); + var change1 = x1[0]; + var text1 = x1[1]; + + var x2 = randomTestChangeset(text1, rand); + var change2 = x2[0]; + var text2 = x2[1]; + + var x3 = randomTestChangeset(text2, rand); + var change3 = x3[0]; + var text3 = x3[1]; + + //print(literal(Changeset.toBaseTen(startText))); + //print(literal(Changeset.toBaseTen(change1))); + //print(literal(Changeset.toBaseTen(change2))); + var change12 = Changeset.checkRep(Changeset.compose(change1, change2, p)); + var change23 = Changeset.checkRep(Changeset.compose(change2, change3, p)); + var change123 = Changeset.checkRep(Changeset.compose(change12, change3, p)); + var change123a = Changeset.checkRep(Changeset.compose(change1, change23, p)); + assertEqualStrings(change123, change123a); + + assertEqualStrings(text2, Changeset.applyToText(change12, startText)); + assertEqualStrings(text3, Changeset.applyToText(change23, text1)); + assertEqualStrings(text3, Changeset.applyToText(change123, startText)); + } + + for(var i=0;i<30;i++) testCompose(i); + + (function simpleComposeAttributesTest() { + print("> simpleComposeAttributesTest"); + var p = new AttribPool(); + p.putAttrib(['bold','']); + p.putAttrib(['bold','true']); + var cs1 = Changeset.checkRep("Z:2>1*1+1*1=1$x"); + var cs2 = Changeset.checkRep("Z:3>0*0|1=3$"); + var cs12 = Changeset.checkRep(Changeset.compose(cs1, cs2, p)); + assertEqualStrings("Z:2>1+1*0|1=2$x", cs12); + })(); + + (function followAttributesTest() { + var p = new AttribPool(); + p.putAttrib(['x','']); + p.putAttrib(['x','abc']); + p.putAttrib(['x','def']); + p.putAttrib(['y','']); + p.putAttrib(['y','abc']); + p.putAttrib(['y','def']); + + function testFollow(a, b, afb, bfa, merge) { + assertEqualStrings(afb, Changeset.followAttributes(a, b, p)); + assertEqualStrings(bfa, Changeset.followAttributes(b, a, p)); + assertEqualStrings(merge, Changeset.composeAttributes(a, afb, true, p)); + assertEqualStrings(merge, Changeset.composeAttributes(b, bfa, true, p)); + } + + testFollow('', '', '', '', ''); + testFollow('*0', '', '', '*0', '*0'); + testFollow('*0', '*0', '', '', '*0'); + testFollow('*0', '*1', '', '*0', '*0'); + testFollow('*1', '*2', '', '*1', '*1'); + testFollow('*0*1', '', '', '*0*1', '*0*1'); + testFollow('*0*4', '*2*3', '*3', '*0', '*0*3'); + testFollow('*0*4', '*2', '', '*0*4', '*0*4'); + })(); + + function testFollow(randomSeed) { + var rand = new java.util.Random(randomSeed + 1000); + print("> testFollow#"+randomSeed); + + var p = new AttribPool(); + + var startText = randomMultiline(10, 20, rand)+'\n'; + + var cs1 = randomTestChangeset(startText, rand)[0]; + var cs2 = randomTestChangeset(startText, rand)[0]; + + var afb = Changeset.checkRep(Changeset.follow(cs1, cs2, false, p)); + var bfa = Changeset.checkRep(Changeset.follow(cs2, cs1, true, p)); + + var merge1 = Changeset.checkRep(Changeset.compose(cs1, afb)); + var merge2 = Changeset.checkRep(Changeset.compose(cs2, bfa)); + + assertEqualStrings(merge1, merge2); + } + + for(var i=0;i<30;i++) testFollow(i); + + function testSplitJoinAttributionLines(randomSeed) { + var rand = new java.util.Random(randomSeed + 2000); + print("> testSplitJoinAttributionLines#"+randomSeed); + + var doc = randomMultiline(10, 20, rand)+'\n'; + + function stringToOps(str) { + var assem = Changeset.mergingOpAssembler(); + var o = Changeset.newOp('+'); + o.chars = 1; + for(var i=0;i<str.length;i++) { + var c = str.charAt(i); + o.lines = (c == '\n' ? 1 : 0); + o.attribs = (c == 'a' || c == 'b' ? '*'+c : ''); + assem.append(o); + } + return assem.toString(); + } + + var theJoined = stringToOps(doc); + var theSplit = doc.match(/[^\n]*\n/g).map(stringToOps); + + assertEqualArrays(theSplit, Changeset.splitAttributionLines(theJoined, doc)); + assertEqualStrings(theJoined, Changeset.joinAttributionLines(theSplit)); + } + + for(var i=0;i<10;i++) testSplitJoinAttributionLines(i); + + (function testMoveOpsToNewPool() { + print("> testMoveOpsToNewPool"); + + var pool1 = new AttribPool(); + var pool2 = new AttribPool(); + + pool1.putAttrib(['baz','qux']); + pool1.putAttrib(['foo','bar']); + + pool2.putAttrib(['foo','bar']); + + assertEqualStrings(Changeset.moveOpsToNewPool('Z:1>2*1+1*0+1$ab', pool1, pool2), 'Z:1>2*0+1*1+1$ab'); + assertEqualStrings(Changeset.moveOpsToNewPool('*1+1*0+1', pool1, pool2), '*0+1*1+1'); + })(); + + + (function testMakeSplice() { + print("> testMakeSplice"); + + var t = "a\nb\nc\n"; + var t2 = Changeset.applyToText(Changeset.makeSplice(t, 5, 0, "def"), t); + assertEqualStrings("a\nb\ncdef\n", t2); + + })(); + + (function testToSplices() { + print("> testToSplices"); + + var cs = Changeset.checkRep('Z:z>9*0=1=4-3+9=1|1-4-4+1*0+a$123456789abcdefghijk'); + var correctSplices = [[5, 8, "123456789"], [9, 17, "abcdefghijk"]]; + assertEqualArrays(correctSplices, Changeset.toSplices(cs)); + })(); + + function testCharacterRangeFollow(testId, cs, oldRange, insertionsAfter, correctNewRange) { + print("> testCharacterRangeFollow#"+testId); + + var cs = Changeset.checkRep(cs); + assertEqualArrays(correctNewRange, Changeset.characterRangeFollow(cs, oldRange[0], oldRange[1], + insertionsAfter)); + + } + + testCharacterRangeFollow(1, 'Z:z>9*0=1=4-3+9=1|1-4-4+1*0+a$123456789abcdefghijk', + [7, 10], false, [14, 15]); + testCharacterRangeFollow(2, "Z:bc<6|x=b4|2-6$", [400, 407], false, [400, 401]); + testCharacterRangeFollow(3, "Z:4>0-3+3$abc", [0,3], false, [3,3]); + testCharacterRangeFollow(4, "Z:4>0-3+3$abc", [0,3], true, [0,0]); + testCharacterRangeFollow(5, "Z:5>1+1=1-3+3$abcd", [1,4], false, [5,5]); + testCharacterRangeFollow(6, "Z:5>1+1=1-3+3$abcd", [1,4], true, [2,2]); + testCharacterRangeFollow(7, "Z:5>1+1=1-3+3$abcd", [0,6], false, [1,7]); + testCharacterRangeFollow(8, "Z:5>1+1=1-3+3$abcd", [0,3], false, [1,2]); + testCharacterRangeFollow(9, "Z:5>1+1=1-3+3$abcd", [2,5], false, [5,6]); + testCharacterRangeFollow(10, "Z:2>1+1$a", [0,0], false, [1,1]); + testCharacterRangeFollow(11, "Z:2>1+1$a", [0,0], true, [0,0]); + + (function testOpAttributeValue() { + print("> testOpAttributeValue"); + + var p = new AttribPool(); + p.putAttrib(['name','david']); + p.putAttrib(['color','green']); + + assertEqualStrings("david", Changeset.opAttributeValue(Changeset.stringOp('*0*1+1'), 'name', p)); + assertEqualStrings("david", Changeset.opAttributeValue(Changeset.stringOp('*0+1'), 'name', p)); + assertEqualStrings("", Changeset.opAttributeValue(Changeset.stringOp('*1+1'), 'name', p)); + assertEqualStrings("", Changeset.opAttributeValue(Changeset.stringOp('+1'), 'name', p)); + assertEqualStrings("green", Changeset.opAttributeValue(Changeset.stringOp('*0*1+1'), 'color', p)); + assertEqualStrings("green", Changeset.opAttributeValue(Changeset.stringOp('*1+1'), 'color', p)); + assertEqualStrings("", Changeset.opAttributeValue(Changeset.stringOp('*0+1'), 'color', p)); + assertEqualStrings("", Changeset.opAttributeValue(Changeset.stringOp('+1'), 'color', p)); + })(); + + function testAppendATextToAssembler(testId, atext, correctOps) { + print("> testAppendATextToAssembler#"+testId); + + var assem = Changeset.smartOpAssembler(); + Changeset.appendATextToAssembler(atext, assem); + assertEqualStrings(correctOps, assem.toString()); + } + + testAppendATextToAssembler(1, {text:"\n", attribs:"|1+1"}, ""); + testAppendATextToAssembler(2, {text:"\n\n", attribs:"|2+2"}, "|1+1"); + testAppendATextToAssembler(3, {text:"\n\n", attribs:"*x|2+2"}, "*x|1+1"); + testAppendATextToAssembler(4, {text:"\n\n", attribs:"*x|1+1|1+1"}, "*x|1+1"); + testAppendATextToAssembler(5, {text:"foo\n", attribs:"|1+4"}, "+3"); + testAppendATextToAssembler(6, {text:"\nfoo\n", attribs:"|2+5"}, "|1+1+3"); + testAppendATextToAssembler(7, {text:"\nfoo\n", attribs:"*x|2+5"}, "*x|1+1*x+3"); + testAppendATextToAssembler(8, {text:"\n\n\nfoo\n", attribs:"|2+2*x|2+5"}, "|2+2*x|1+1*x+3"); + + function testMakeAttribsString(testId, pool, opcode, attribs, correctString) { + print("> testMakeAttribsString#"+testId); + + var p = poolOrArray(pool); + var str = Changeset.makeAttribsString(opcode, attribs, p); + assertEqualStrings(correctString, str); + } + + testMakeAttribsString(1, ['bold,'], '+', [['bold','']], ''); + testMakeAttribsString(2, ['abc,def','bold,'], '=', [['bold','']], '*1'); + testMakeAttribsString(3, ['abc,def','bold,true'], '+', [['abc','def'],['bold','true']], '*0*1'); + testMakeAttribsString(4, ['abc,def','bold,true'], '+', [['bold','true'],['abc','def']], '*0*1'); + + function testSubattribution(testId, astr, start, end, correctOutput) { + print("> testSubattribution#"+testId); + + var str = Changeset.subattribution(astr, start, end); + assertEqualStrings(correctOutput, str); + } + + testSubattribution(1, "+1", 0, 0, ""); + testSubattribution(2, "+1", 0, 1, "+1"); + testSubattribution(3, "+1", 0, undefined, "+1"); + testSubattribution(4, "|1+1", 0, 0, ""); + testSubattribution(5, "|1+1", 0, 1, "|1+1"); + testSubattribution(6, "|1+1", 0, undefined, "|1+1"); + testSubattribution(7, "*0+1", 0, 0, ""); + testSubattribution(8, "*0+1", 0, 1, "*0+1"); + testSubattribution(9, "*0+1", 0, undefined, "*0+1"); + testSubattribution(10, "*0|1+1", 0, 0, ""); + testSubattribution(11, "*0|1+1", 0, 1, "*0|1+1"); + testSubattribution(12, "*0|1+1", 0, undefined, "*0|1+1"); + testSubattribution(13, "*0+2+1*1+3", 0, 1, "*0+1"); + testSubattribution(14, "*0+2+1*1+3", 0, 2, "*0+2"); + testSubattribution(15, "*0+2+1*1+3", 0, 3, "*0+2+1"); + testSubattribution(16, "*0+2+1*1+3", 0, 4, "*0+2+1*1+1"); + testSubattribution(17, "*0+2+1*1+3", 0, 5, "*0+2+1*1+2"); + testSubattribution(18, "*0+2+1*1+3", 0, 6, "*0+2+1*1+3"); + testSubattribution(19, "*0+2+1*1+3", 0, 7, "*0+2+1*1+3"); + testSubattribution(20, "*0+2+1*1+3", 0, undefined, "*0+2+1*1+3"); + testSubattribution(21, "*0+2+1*1+3", 1, undefined, "*0+1+1*1+3"); + testSubattribution(22, "*0+2+1*1+3", 2, undefined, "+1*1+3"); + testSubattribution(23, "*0+2+1*1+3", 3, undefined, "*1+3"); + testSubattribution(24, "*0+2+1*1+3", 4, undefined, "*1+2"); + testSubattribution(25, "*0+2+1*1+3", 5, undefined, "*1+1"); + testSubattribution(26, "*0+2+1*1+3", 6, undefined, ""); + testSubattribution(27, "*0+2+1*1|1+3", 0, 1, "*0+1"); + testSubattribution(28, "*0+2+1*1|1+3", 0, 2, "*0+2"); + testSubattribution(29, "*0+2+1*1|1+3", 0, 3, "*0+2+1"); + testSubattribution(30, "*0+2+1*1|1+3", 0, 4, "*0+2+1*1+1"); + testSubattribution(31, "*0+2+1*1|1+3", 0, 5, "*0+2+1*1+2"); + testSubattribution(32, "*0+2+1*1|1+3", 0, 6, "*0+2+1*1|1+3"); + testSubattribution(33, "*0+2+1*1|1+3", 0, 7, "*0+2+1*1|1+3"); + testSubattribution(34, "*0+2+1*1|1+3", 0, undefined, "*0+2+1*1|1+3"); + testSubattribution(35, "*0+2+1*1|1+3", 1, undefined, "*0+1+1*1|1+3"); + testSubattribution(36, "*0+2+1*1|1+3", 2, undefined, "+1*1|1+3"); + testSubattribution(37, "*0+2+1*1|1+3", 3, undefined, "*1|1+3"); + testSubattribution(38, "*0+2+1*1|1+3", 4, undefined, "*1|1+2"); + testSubattribution(39, "*0+2+1*1|1+3", 5, undefined, "*1|1+1"); + testSubattribution(40, "*0+2+1*1|1+3", 1, 5, "*0+1+1*1+2"); + testSubattribution(41, "*0+2+1*1|1+3", 2, 6, "+1*1|1+3"); + testSubattribution(42, "*0+2+1*1+3", 2, 6, "+1*1+3"); + + function testFilterAttribNumbers(testId, cs, filter, correctOutput) { + print("> testFilterAttribNumbers#"+testId); + + var str = Changeset.filterAttribNumbers(cs, filter); + assertEqualStrings(correctOutput, str); + } + + testFilterAttribNumbers(1, "*0*1+1+2+3*1+4*2+5*0*2*1*b*c+6", + function(n) { return (n%2) == 0; }, + "*0+1+2+3+4*2+5*0*2*c+6"); + testFilterAttribNumbers(2, "*0*1+1+2+3*1+4*2+5*0*2*1*b*c+6", + function(n) { return (n%2) == 1; }, + "*1+1+2+3*1+4+5*1*b+6"); + + function testInverse(testId, cs, lines, alines, pool, correctOutput) { + print("> testInverse#"+testId); + + pool = poolOrArray(pool); + var str = Changeset.inverse(Changeset.checkRep(cs), lines, alines, pool); + assertEqualStrings(correctOutput, str); + } + + // take "FFFFTTTTT" and apply "-FT--FFTT", the inverse of which is "--F--TT--" + testInverse(1, "Z:9>0=1*0=1*1=1=2*0=2*1|1=2$", null, ["+4*1+5"], ['bold,','bold,true'], + "Z:9>0=2*0=1=2*1=2$"); + + function testMutateTextLines(testId, cs, lines, correctLines) { + print("> testMutateTextLines#"+testId); + + var a = lines.slice(); + Changeset.mutateTextLines(cs, a); + assertEqualArrays(correctLines, a); + } + + testMutateTextLines(1, "Z:4<1|1-2-1|1+1+1$\nc", ["a\n", "b\n"], ["\n", "c\n"]); + testMutateTextLines(2, "Z:4>0|1-2-1|2+3$\nc\n", ["a\n", "b\n"], ["\n", "c\n", "\n"]); + + function testInverseRandom(randomSeed) { + var rand = new java.util.Random(randomSeed + 3000); + print("> testInverseRandom#"+randomSeed); + + var p = poolOrArray(['apple,','apple,true','banana,','banana,true']); + + var startText = randomMultiline(10, 20, rand)+'\n'; + var alines = Changeset.splitAttributionLines(Changeset.makeAttribution(startText), startText); + var lines = startText.slice(0,-1).split('\n').map(function(s) { return s+'\n'; }); + + var stylifier = randomTestChangeset(startText, rand, true)[0]; + + //print(alines.join('\n')); + Changeset.mutateAttributionLines(stylifier, alines, p); + //print(stylifier); + //print(alines.join('\n')); + Changeset.mutateTextLines(stylifier, lines); + + var changeset = randomTestChangeset(lines.join(''), rand, true)[0]; + var inverseChangeset = Changeset.inverse(changeset, lines, alines, p); + + var origLines = lines.slice(); + var origALines = alines.slice(); + + Changeset.mutateTextLines(changeset, lines); + Changeset.mutateAttributionLines(changeset, alines, p); + //print(origALines.join('\n')); + //print(changeset); + //print(inverseChangeset); + //print(origLines.map(function(s) { return '1: '+s.slice(0,-1); }).join('\n')); + //print(lines.map(function(s) { return '2: '+s.slice(0,-1); }).join('\n')); + //print(alines.join('\n')); + Changeset.mutateTextLines(inverseChangeset, lines); + Changeset.mutateAttributionLines(inverseChangeset, alines, p); + //print(lines.map(function(s) { return '3: '+s.slice(0,-1); }).join('\n')); + + assertEqualArrays(origLines, lines); + assertEqualArrays(origALines, alines); + } + + for(var i=0;i<30;i++) testInverseRandom(i); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/collab/ace/linestylefilter.js b/trunk/etherpad/src/etherpad/collab/ace/linestylefilter.js new file mode 100644 index 0000000..c7f79a5 --- /dev/null +++ b/trunk/etherpad/src/etherpad/collab/ace/linestylefilter.js @@ -0,0 +1,253 @@ +// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/linestylefilter.js +import("etherpad.collab.ace.easysync2.Changeset"); + +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// requires: easysync2.Changeset + +var linestylefilter = {}; + +linestylefilter.ATTRIB_CLASSES = { + 'bold':'tag:b', + 'italic':'tag:i', + 'underline':'tag:u', + 'strikethrough':'tag:s', + 'h1':'tag:h1', + 'h2':'tag:h2', + 'h3':'tag:h3', + 'h4':'tag:h4', + 'h5':'tag:h5', + 'h6':'tag:h6' +}; + +linestylefilter.getAuthorClassName = function(author) { + return "author-"+author.replace(/[^a-y0-9]/g, function(c) { + if (c == ".") return "-"; + return 'z'+c.charCodeAt(0)+'z'; + }); +}; + +// lineLength is without newline; aline includes newline, +// but may be falsy if lineLength == 0 +linestylefilter.getLineStyleFilter = function(lineLength, aline, + textAndClassFunc, apool) { + + if (lineLength == 0) return textAndClassFunc; + + var nextAfterAuthorColors = textAndClassFunc; + + var authorColorFunc = (function() { + var lineEnd = lineLength; + var curIndex = 0; + var extraClasses; + var leftInAuthor; + + function attribsToClasses(attribs) { + var classes = ''; + Changeset.eachAttribNumber(attribs, function(n) { + var key = apool.getAttribKey(n); + if (key) { + var value = apool.getAttribValue(n); + if (value) { + if (key == 'author') { + classes += ' '+linestylefilter.getAuthorClassName(value); + } + else if (key == 'list') { + classes += ' list:'+value; + } + else if (linestylefilter.ATTRIB_CLASSES[key]) { + classes += ' '+linestylefilter.ATTRIB_CLASSES[key]; + } + } + } + }); + return classes.substring(1); + } + + var attributionIter = Changeset.opIterator(aline); + var nextOp, nextOpClasses; + function goNextOp() { + nextOp = attributionIter.next(); + nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs)); + } + goNextOp(); + function nextClasses() { + if (curIndex < lineEnd) { + extraClasses = nextOpClasses; + leftInAuthor = nextOp.chars; + goNextOp(); + while (nextOp.opcode && nextOpClasses == extraClasses) { + leftInAuthor += nextOp.chars; + goNextOp(); + } + } + } + nextClasses(); + + return function(txt, cls) { + while (txt.length > 0) { + if (leftInAuthor <= 0) { + // prevent infinite loop if something funny's going on + return nextAfterAuthorColors(txt, cls); + } + var spanSize = txt.length; + if (spanSize > leftInAuthor) { + spanSize = leftInAuthor; + } + var curTxt = txt.substring(0, spanSize); + txt = txt.substring(spanSize); + nextAfterAuthorColors(curTxt, (cls&&cls+" ")+extraClasses); + curIndex += spanSize; + leftInAuthor -= spanSize; + if (leftInAuthor == 0) { + nextClasses(); + } + } + }; + })(); + return authorColorFunc; +}; + +linestylefilter.getAtSignSplitterFilter = function(lineText, + textAndClassFunc) { + var at = /@/g; + at.lastIndex = 0; + var splitPoints = null; + var execResult; + while ((execResult = at.exec(lineText))) { + if (! splitPoints) { + splitPoints = []; + } + splitPoints.push(execResult.index); + } + + if (! splitPoints) return textAndClassFunc; + + return linestylefilter.textAndClassFuncSplitter(textAndClassFunc, + splitPoints); +}; + +linestylefilter.REGEX_WORDCHAR = /[\u0030-\u0039\u0041-\u005A\u0061-\u007A\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF\u0100-\u1FFF\u3040-\u9FFF\uF900-\uFDFF\uFE70-\uFEFE\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFDC]/; +linestylefilter.REGEX_URLCHAR = new RegExp('('+/[-:@a-zA-Z0-9_.,~%+\/\\?=&#;()$]/.source+'|'+linestylefilter.REGEX_WORDCHAR.source+')'); +linestylefilter.REGEX_URL = new RegExp(/(?:(?:https?|s?ftp|ftps|file|smb|afp|nfs|(x-)?man|gopher|txmt):\/\/|mailto:)/.source+linestylefilter.REGEX_URLCHAR.source+'*(?![:.,;])'+linestylefilter.REGEX_URLCHAR.source, 'g'); + +linestylefilter.getURLFilter = function(lineText, textAndClassFunc) { + linestylefilter.REGEX_URL.lastIndex = 0; + var urls = null; + var splitPoints = null; + var execResult; + while ((execResult = linestylefilter.REGEX_URL.exec(lineText))) { + if (! urls) { + urls = []; + splitPoints = []; + } + var startIndex = execResult.index; + var url = execResult[0]; + urls.push([startIndex, url]); + splitPoints.push(startIndex, startIndex + url.length); + } + + if (! urls) return textAndClassFunc; + + function urlForIndex(idx) { + for(var k=0; k<urls.length; k++) { + var u = urls[k]; + if (idx >= u[0] && idx < u[0]+u[1].length) { + return u[1]; + } + } + return false; + } + + var handleUrlsAfterSplit = (function() { + var curIndex = 0; + return function(txt, cls) { + var txtlen = txt.length; + var newCls = cls; + var url = urlForIndex(curIndex); + if (url) { + newCls += " url:"+url; + } + textAndClassFunc(txt, newCls); + curIndex += txtlen; + }; + })(); + + return linestylefilter.textAndClassFuncSplitter(handleUrlsAfterSplit, + splitPoints); +}; + +linestylefilter.textAndClassFuncSplitter = function(func, splitPointsOpt) { + var nextPointIndex = 0; + var idx = 0; + + // don't split at 0 + while (splitPointsOpt && + nextPointIndex < splitPointsOpt.length && + splitPointsOpt[nextPointIndex] == 0) { + nextPointIndex++; + } + + function spanHandler(txt, cls) { + if ((! splitPointsOpt) || nextPointIndex >= splitPointsOpt.length) { + func(txt, cls); + idx += txt.length; + } + else { + var splitPoints = splitPointsOpt; + var pointLocInSpan = splitPoints[nextPointIndex] - idx; + var txtlen = txt.length; + if (pointLocInSpan >= txtlen) { + func(txt, cls); + idx += txt.length; + if (pointLocInSpan == txtlen) { + nextPointIndex++; + } + } + else { + if (pointLocInSpan > 0) { + func(txt.substring(0, pointLocInSpan), cls); + idx += pointLocInSpan; + } + nextPointIndex++; + // recurse + spanHandler(txt.substring(pointLocInSpan), cls); + } + } + } + return spanHandler; +}; + +// domLineObj is like that returned by domline.createDomLine +linestylefilter.populateDomLine = function(textLine, aline, apool, + domLineObj) { + // remove final newline from text if any + var text = textLine; + if (text.slice(-1) == '\n') { + text = text.substring(0, text.length-1); + } + + function textAndClassFunc(tokenText, tokenClass) { + domLineObj.appendSpan(tokenText, tokenClass); + } + + var func = textAndClassFunc; + func = linestylefilter.getURLFilter(text, func); + func = linestylefilter.getLineStyleFilter(text.length, aline, + func, apool); + func(text, ''); +}; diff --git a/trunk/etherpad/src/etherpad/collab/collab_server.js b/trunk/etherpad/src/etherpad/collab/collab_server.js new file mode 100644 index 0000000..78c9921 --- /dev/null +++ b/trunk/etherpad/src/etherpad/collab/collab_server.js @@ -0,0 +1,778 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("comet"); +import("ejs"); +import("etherpad.collab.ace.easysync2.{AttribPool,Changeset}"); +import("etherpad.log"); +import("etherpad.pad.activepads"); +import("etherpad.pad.model"); +import("etherpad.pad.padutils"); +import("etherpad.pad.padusers"); +import("etherpad.pad.padevents"); +import("etherpad.pad.pad_security"); +import("etherpad.pro.pro_padmeta"); +import("fastJSON"); +import("fileutils.readFile"); +import("jsutils.{eachProperty,keys}"); +import("etherpad.collab.collabroom_server.*"); +import("etherpad.collab.readonly_server"); +jimport("java.util.concurrent.ConcurrentHashMap"); + +var PADPAGE_ROOMTYPE = "padpage"; + +function onStartup() { + +} + +function _padIdToRoom(padId) { + return "padpage/"+padId; +} + +function _roomToPadId(roomName) { + return roomName.substring(roomName.indexOf("/")+1); +} + +function removeFromMemory(pad) { + // notification so we can free stuff + if (getNumConnections(pad) == 0) { + var tempObj = pad.tempObj(); + tempObj.revisionSockets = {}; + } +} + +function _getPadConnections(pad) { + return getRoomConnections(_padIdToRoom(pad.getId())); +} + +function guestKnock(globalPadId, guestId, displayName) { + var askedSomeone = false; + + // requires that we somehow have permission on this pad + model.accessPadGlobal(globalPadId, function(pad) { + var connections = _getPadConnections(pad); + connections.forEach(function(connection) { + // only send to pro users + if (! padusers.isGuest(connection.data.userInfo.userId)) { + askedSomeone = true; + var msg = { type: "SERVER_MESSAGE", + payload: { type: 'GUEST_PROMPT', + userId: guestId, + displayName: displayName } }; + sendMessage(connection.connectionId, msg); + } + }); + }); + + if (! askedSomeone) { + pad_security.answerKnock(guestId, globalPadId, "denied"); + } +} + +function _verifyUserId(userId) { + var result; + if (padusers.isGuest(userId)) { + // allow cookie-verified guest even if user has signed in + result = (userId == padusers.getGuestUserId()); + } + else { + result = (userId == padusers.getUserId()); + } + return result; +} + +function _checkChangesetAndPool(cs, pool) { + Changeset.checkRep(cs); + Changeset.eachAttribNumber(cs, function(n) { + if (! pool.getAttrib(n)) { + throw new Error("Attribute pool is missing attribute "+n+" for changeset "+cs); + } + }); +} + +function _doWarn(str) { + log.warn(appjet.executionId+": "+str); +} + +function _doInfo(str) { + log.info(appjet.executionId+": "+str); +} + +function _getPadRevisionSockets(pad) { + var revisionSockets = pad.tempObj().revisionSockets; + if (! revisionSockets) { + revisionSockets = {}; // rev# -> socket id + pad.tempObj().revisionSockets = revisionSockets; + } + return revisionSockets; +} + +function applyUserChanges(pad, baseRev, changeset, optSocketId, optAuthor) { + // changeset must be already adapted to the server's apool + + var apool = pad.pool(); + var r = baseRev; + while (r < pad.getHeadRevisionNumber()) { + r++; + var c = pad.getRevisionChangeset(r); + changeset = Changeset.follow(c, changeset, false, apool); + } + + var prevText = pad.text(); + if (Changeset.oldLen(changeset) != prevText.length) { + _doWarn("Can't apply USER_CHANGES "+changeset+" to document of length "+ + prevText.length); + return; + } + + var thisAuthor = ''; + if (optSocketId) { + var connectionId = getSocketConnectionId(optSocketId); + if (connectionId) { + var connection = getConnection(connectionId); + if (connection) { + thisAuthor = connection.data.userInfo.userId; + } + } + } + if (optAuthor) { + thisAuthor = optAuthor; + } + + pad.appendRevision(changeset, thisAuthor); + var newRev = pad.getHeadRevisionNumber(); + if (optSocketId) { + _getPadRevisionSockets(pad)[newRev] = optSocketId; + } + + var correctionChangeset = _correctMarkersInPad(pad.atext(), pad.pool()); + if (correctionChangeset) { + pad.appendRevision(correctionChangeset); + } + + ///// make document end in blank line if it doesn't: + if (pad.text().lastIndexOf("\n\n") != pad.text().length-2) { + var nlChangeset = Changeset.makeSplice( + pad.text(), pad.text().length-1, 0, "\n"); + pad.appendRevision(nlChangeset); + } + + updatePadClients(pad); + + activepads.touch(pad.getId()); + padevents.onEditPad(pad, thisAuthor); +} + +function updateClient(pad, connectionId) { + var conn = getConnection(connectionId); + if (! conn) { + return; + } + var lastRev = conn.data.lastRev; + var userId = conn.data.userInfo.userId; + var socketId = conn.socketId; + while (lastRev < pad.getHeadRevisionNumber()) { + var r = ++lastRev; + var author = pad.getRevisionAuthor(r); + var revisionSockets = _getPadRevisionSockets(pad); + if (revisionSockets[r] === socketId) { + sendMessage(connectionId, {type:"ACCEPT_COMMIT", newRev:r}); + } + else { + var forWire = Changeset.prepareForWire(pad.getRevisionChangeset(r), pad.pool()); + var msg = {type:"NEW_CHANGES", newRev:r, + changeset: forWire.translated, + apool: forWire.pool, + author: author}; + sendMessage(connectionId, msg); + } + } + conn.data.lastRev = pad.getHeadRevisionNumber(); + updateRoomConnectionData(connectionId, conn.data); +} + +function updatePadClients(pad) { + _getPadConnections(pad).forEach(function(connection) { + updateClient(pad, connection.connectionId); + }); + + readonly_server.updatePadClients(pad); +} + +function applyMissedChanges(pad, missedChanges) { + var userInfo = missedChanges.userInfo; + var baseRev = missedChanges.baseRev; + var committedChangeset = missedChanges.committedChangeset; // may be falsy + var furtherChangeset = missedChanges.furtherChangeset; // may be falsy + var apool = pad.pool(); + + if (! _verifyUserId(userInfo.userId)) { + return; + } + + if (committedChangeset) { + var wireApool1 = (new AttribPool()).fromJsonable(missedChanges.committedChangesetAPool); + _checkChangesetAndPool(committedChangeset, wireApool1); + committedChangeset = pad.adoptChangesetAttribs(committedChangeset, wireApool1); + } + if (furtherChangeset) { + var wireApool2 = (new AttribPool()).fromJsonable(missedChanges.furtherChangesetAPool); + _checkChangesetAndPool(furtherChangeset, wireApool2); + furtherChangeset = pad.adoptChangesetAttribs(furtherChangeset, wireApool2); + } + + var commitWasMissed = !! committedChangeset; + if (commitWasMissed) { + var commitSocketId = missedChanges.committedChangesetSocketId; + var revisionSockets = _getPadRevisionSockets(pad); + // was the commit really missed, or did the client just not hear back? + // look for later changeset by this socket + var r = baseRev; + while (r < pad.getHeadRevisionNumber()) { + r++; + var s = revisionSockets[r]; + if (! s) { + // changes are too old, have to drop them. + return; + } + if (s == commitSocketId) { + commitWasMissed = false; + break; + } + } + } + if (! commitWasMissed) { + // commit already incorporated by the server + committedChangeset = null; + } + + var changeset; + if (committedChangeset && furtherChangeset) { + changeset = Changeset.compose(committedChangeset, furtherChangeset, apool); + } + else { + changeset = (committedChangeset || furtherChangeset); + } + + if (changeset) { + var author = userInfo.userId; + + applyUserChanges(pad, baseRev, changeset, null, author); + } +} + +function getAllPadsWithConnections() { + // returns array of global pad id strings + return getAllRoomsOfType(PADPAGE_ROOMTYPE).map(_roomToPadId); +} + +function broadcastServerMessage(msgObj) { + var msg = {type: "SERVER_MESSAGE", payload: msgObj}; + getAllRoomsOfType(PADPAGE_ROOMTYPE).forEach(function(roomName) { + getRoomConnections(roomName).forEach(function(connection) { + sendMessage(connection.connectionId, msg); + }); + }); +} + +function appendPadText(pad, txt) { + txt = model.cleanText(txt); + var oldFullText = pad.text(); + _applyChangesetToPad(pad, Changeset.makeSplice(oldFullText, + oldFullText.length-1, 0, txt)); +} + +function setPadText(pad, txt) { + txt = model.cleanText(txt); + var oldFullText = pad.text(); + // replace text except for the existing final (virtual) newline + _applyChangesetToPad(pad, Changeset.makeSplice(oldFullText, 0, + oldFullText.length-1, txt)); +} + +function setPadAText(pad, atext) { + var oldFullText = pad.text(); + var deletion = Changeset.makeSplice(oldFullText, 0, oldFullText.length-1, ""); + + var assem = Changeset.smartOpAssembler(); + Changeset.appendATextToAssembler(atext, assem); + var charBank = atext.text.slice(0, -1); + var insertion = Changeset.checkRep(Changeset.pack(1, atext.text.length, + assem.toString(), charBank)); + + var cs = Changeset.compose(deletion, insertion, pad.pool()); + Changeset.checkRep(cs); + + _applyChangesetToPad(pad, cs); +} + +function applyChangesetToPad(pad, changeset) { + Changeset.checkRep(changeset); + + _applyChangesetToPad(pad, changeset); +} + +function _applyChangesetToPad(pad, changeset) { + pad.appendRevision(changeset); + updatePadClients(pad); +} + +function getHistoricalAuthorData(pad, author) { + var authorData = pad.getAuthorData(author); + if (authorData) { + var data = {}; + if ((typeof authorData.colorId) == "number") { + data.colorId = authorData.colorId; + } + if (authorData.name) { + data.name = authorData.name; + } + else { + var uname = padusers.getNameForUserId(author); + if (uname) { + data.name = uname; + } + } + return data; + } + return null; +} + +function buildHistoricalAuthorDataMapFromAText(pad, atext) { + var map = {}; + pad.eachATextAuthor(atext, function(author, authorNum) { + var data = getHistoricalAuthorData(pad, author); + if (data) { + map[author] = data; + } + }); + return map; +} + +function buildHistoricalAuthorDataMapForPadHistory(pad) { + var map = {}; + pad.pool().eachAttrib(function(key, value) { + if (key == 'author') { + var author = value; + var data = getHistoricalAuthorData(pad, author); + if (data) { + map[author] = data; + } + } + }); + return map; +} + +function getATextForWire(pad, optRev) { + var atext; + if ((optRev && ! isNaN(Number(optRev))) || (typeof optRev) == "number") { + atext = pad.getInternalRevisionAText(Number(optRev)); + } + else { + atext = pad.atext(); + } + + var historicalAuthorData = buildHistoricalAuthorDataMapFromAText(pad, atext); + + var attribsForWire = Changeset.prepareForWire(atext.attribs, pad.pool()); + var apool = attribsForWire.pool; + // mutate atext (translate attribs for wire): + atext.attribs = attribsForWire.translated; + + return {atext:atext, apool:apool.toJsonable(), + historicalAuthorData:historicalAuthorData }; +} + +function getCollabClientVars(pad) { + // construct object that is made available on the client + // as collab_client_vars + + var forWire = getATextForWire(pad); + + return { + initialAttributedText: forWire.atext, + rev: pad.getHeadRevisionNumber(), + padId: pad.getLocalId(), + globalPadId: pad.getId(), + historicalAuthorData: forWire.historicalAuthorData, + apool: forWire.apool, + clientIp: request.clientAddr, + clientAgent: request.headers["User-Agent"] + }; +} + +function getNumConnections(pad) { + return _getPadConnections(pad).length; +} + +function getConnectedUsers(pad) { + var users = []; + _getPadConnections(pad).forEach(function(connection) { + users.push(connection.data.userInfo); + }); + return users; +} + + +function bootAllUsersFromPad(pad, reason) { + return bootUsersFromPad(pad, reason); +} + +function bootUsersFromPad(pad, reason, userInfoFilter) { + var connections = _getPadConnections(pad); + var bootedUserInfos = []; + connections.forEach(function(connection) { + if ((! userInfoFilter) || userInfoFilter(connection.data.userInfo)) { + bootedUserInfos.push(connection.data.userInfo); + bootConnection(connection.connectionId); + } + }); + return bootedUserInfos; +} + +function dumpStorageToString(pad) { + var lines = []; + var errors = []; + var head = pad.getHeadRevisionNumber(); + try { + for(var i=0;i<=head;i++) { + lines.push("changeset "+i+" "+Changeset.toBaseTen(pad.getRevisionChangeset(i))); + } + } + catch (e) { + errors.push("!!!!! Error in changeset "+i+": "+e.message); + } + for(var i=0;i<=head;i++) { + lines.push("author "+i+" "+pad.getRevisionAuthor(i)); + } + for(var i=0;i<=head;i++) { + lines.push("time "+i+" "+pad.getRevisionDate(i)); + } + var revisionSockets = _getPadRevisionSockets(pad); + for(var k in revisionSockets) lines.push("socket "+k+" "+revisionSockets[k]); + return errors.concat(lines).join('\n'); +} + +function _getPadIdForSocket(socketId) { + var connectionId = getSocketConnectionId(socketId); + if (connectionId) { + var connection = getConnection(connectionId); + if (connection) { + return _roomToPadId(connection.roomName); + } + } + return null; +} + +function _getUserIdForSocket(socketId) { + var connectionId = getSocketConnectionId(socketId); + if (connectionId) { + var connection = getConnection(connectionId); + if (connection) { + return connection.data.userInfo.userId; + } + } + return null; +} + +function _serverDebug(msg) { /* nothing */ } + +function _accessSocketPad(socketId, accessType, padFunc, dontRequirePad) { + return _accessCollabPad(_getPadIdForSocket(socketId), accessType, + padFunc, dontRequirePad); +} + +function _accessConnectionPad(connection, accessType, padFunc, dontRequirePad) { + return _accessCollabPad(_roomToPadId(connection.roomName), accessType, + padFunc, dontRequirePad); +} + +function _accessCollabPad(padId, accessType, padFunc, dontRequirePad) { + if (! padId) { + if (! dontRequirePad) { + _doWarn("Collab operation \""+accessType+"\" aborted because socket "+socketId+" has no pad."); + } + return; + } + else { + return _accessExistingPad(padId, accessType, function(pad) { + return padFunc(pad); + }, dontRequirePad); + } +} + +function _accessExistingPad(padId, accessType, padFunc, dontRequireExist) { + return model.accessPadGlobal(padId, function(pad) { + if (! pad.exists()) { + if (! dontRequireExist) { + _doWarn("Collab operation \""+accessType+"\" aborted because pad "+padId+" doesn't exist."); + } + return; + } + else { + return padFunc(pad); + } + }); +} + +function _handlePadUserInfo(pad, userInfo) { + var author = userInfo.userId; + var colorId = Number(userInfo.colorId); + var name = userInfo.name; + + if (! author) return; + + // update map from author to that author's last known color and name + var data = {colorId: colorId}; + if (name) data.name = name; + pad.setAuthorData(author, data); + padusers.notifyUserData(data); +} + +function _sendUserInfoMessage(connectionId, type, userInfo) { + if (translateSpecialKey(userInfo.specialKey) != 'invisible') { + sendMessage(connectionId, {type: type, userInfo: userInfo }); + } +} + + +function getRoomCallbacks(roomName) { + var callbacks = {}; + callbacks.introduceUsers = + function (joiningConnection, existingConnection) { + // notify users of each other + _sendUserInfoMessage(existingConnection.connectionId, + "USER_NEWINFO", + joiningConnection.data.userInfo); + _sendUserInfoMessage(joiningConnection.connectionId, + "USER_NEWINFO", + existingConnection.data.userInfo); + }; + callbacks.extroduceUsers = + function (leavingConnection, existingConnection) { + _sendUserInfoMessage(existingConnection.connectionId, "USER_LEAVE", + leavingConnection.data.userInfo); + }; + callbacks.onAddConnection = + function (data) { + model.accessPadGlobal(_roomToPadId(roomName), function(pad) { + _handlePadUserInfo(pad, data.userInfo); + padevents.onUserJoin(pad, data.userInfo); + readonly_server.updateUserInfo(pad, data.userInfo); + }); + }; + callbacks.onRemoveConnection = + function (data) { + model.accessPadGlobal(_roomToPadId(roomName), function(pad) { + padevents.onUserLeave(pad, data.userInfo); + }); + }; + callbacks.handleConnect = + function (data) { + if (roomName.indexOf("padpage/") != 0) { + return null; + } + if (! (data.userInfo && data.userInfo.userId && + _verifyUserId(data.userInfo.userId))) { + return null; + } + return data.userInfo; + }; + callbacks.clientReady = + function(newConnection, data) { + var padId = _roomToPadId(newConnection.roomName); + + if (data.stats) { + log.custom("padclientstats", {padId:padId, stats:data.stats}); + } + + var lastRev = data.lastRev; + var isReconnectOf = data.isReconnectOf; + var isCommitPending = !! data.isCommitPending; + var connectionId = newConnection.connectionId; + + newConnection.data.lastRev = lastRev; + updateRoomConnectionData(connectionId, newConnection.data); + + if (padutils.isProPadId(padId)) { + pro_padmeta.accessProPad(padId, function(propad) { + // tell client about pad title + sendMessage(connectionId, {type: "CLIENT_MESSAGE", payload: { + type: "padtitle", title: propad.getDisplayTitle() } }); + sendMessage(connectionId, {type: "CLIENT_MESSAGE", payload: { + type: "padpassword", password: propad.getPassword() } }); + }); + } + + _accessExistingPad(padId, "CLIENT_READY", function(pad) { + sendMessage(connectionId, {type: "CLIENT_MESSAGE", payload: { + type: "padoptions", options: pad.getPadOptionsObj() } }); + + updateClient(pad, connectionId); + + }); + + if (isCommitPending) { + // tell client that if it hasn't received an ACCEPT_COMMIT by now, it isn't coming. + sendMessage(connectionId, {type:"NO_COMMIT_PENDING"}); + } + }; + callbacks.handleMessage = function(connection, msg) { + _handleCometMessage(connection, msg); + }; + return callbacks; +} + +var _specialKeys = [['x375b', 'invisible']]; + +function translateSpecialKey(specialKey) { + // code -> name + for(var i=0;i<_specialKeys.length;i++) { + if (_specialKeys[i][0] == specialKey) { + return _specialKeys[i][1]; + } + } + return null; +} + +function getSpecialKey(name) { + // name -> code + for(var i=0;i<_specialKeys.length;i++) { + if (_specialKeys[i][1] == name) { + return _specialKeys[i][0]; + } + } + return null; +} + +function _updateDocumentConnectionUserInfo(pad, socketId, userInfo) { + var connectionId = getSocketConnectionId(socketId); + if (connectionId) { + var updatingConnection = getConnection(connectionId); + updatingConnection.data.userInfo = userInfo; + updateRoomConnectionData(connectionId, updatingConnection.data); + _getPadConnections(pad).forEach(function(connection) { + if (connection.socketId != updatingConnection.socketId) { + _sendUserInfoMessage(connection.connectionId, + "USER_NEWINFO", userInfo); + } + }); + + _handlePadUserInfo(pad, userInfo); + padevents.onUserInfoChange(pad, userInfo); + readonly_server.updateUserInfo(pad, userInfo); + } +} + +function _handleCometMessage(connection, msg) { + + var socketUserId = connection.data.userInfo.userId; + if (! (socketUserId && _verifyUserId(socketUserId))) { + // user has signed out or cleared cookies, no longer auth'ed + bootConnection(connection.connectionId, "unauth"); + } + + if (msg.type == "USER_CHANGES") { + try { + _accessConnectionPad(connection, "USER_CHANGES", function(pad) { + var baseRev = msg.baseRev; + var wireApool = (new AttribPool()).fromJsonable(msg.apool); + var changeset = msg.changeset; + if (changeset) { + _checkChangesetAndPool(changeset, wireApool); + changeset = pad.adoptChangesetAttribs(changeset, wireApool); + applyUserChanges(pad, baseRev, changeset, connection.socketId); + } + }); + } + catch (e if e.easysync) { + _doWarn("Changeset error handling USER_CHANGES: "+e); + } + } + else if (msg.type == "USERINFO_UPDATE") { + _accessConnectionPad(connection, "USERINFO_UPDATE", function(pad) { + var userInfo = msg.userInfo; + // security check + if (userInfo.userId == connection.data.userInfo.userId) { + _updateDocumentConnectionUserInfo(pad, + connection.socketId, userInfo); + } + else { + // drop on the floor + } + }); + } + else if (msg.type == "CLIENT_MESSAGE") { + _accessConnectionPad(connection, "CLIENT_MESSAGE", function(pad) { + var payload = msg.payload; + if (payload.authId && + payload.authId != connection.data.userInfo.userId) { + // authId, if present, must actually be the sender's userId; + // here it wasn't + } + else { + getRoomConnections(connection.roomName).forEach( + function(conn) { + if (conn.socketId != connection.socketId) { + sendMessage(conn.connectionId, + {type: "CLIENT_MESSAGE", payload: payload}); + } + }); + padevents.onClientMessage(pad, connection.data.userInfo, + payload); + } + }); + } +} + +function _correctMarkersInPad(atext, apool) { + var text = atext.text; + + // collect char positions of line markers (e.g. bullets) in new atext + // that aren't at the start of a line + var badMarkers = []; + var iter = Changeset.opIterator(atext.attribs); + var offset = 0; + while (iter.hasNext()) { + var op = iter.next(); + var listValue = Changeset.opAttributeValue(op, 'list', apool); + if (listValue) { + for(var i=0;i<op.chars;i++) { + if (offset > 0 && text.charAt(offset-1) != '\n') { + badMarkers.push(offset); + } + offset++; + } + } + else { + offset += op.chars; + } + } + + if (badMarkers.length == 0) { + return null; + } + + // create changeset that removes these bad markers + offset = 0; + var builder = Changeset.builder(text.length); + badMarkers.forEach(function(pos) { + builder.keepText(text.substring(offset, pos)); + builder.remove(1); + offset = pos+1; + }); + return builder.toString(); +} diff --git a/trunk/etherpad/src/etherpad/collab/collabroom_server.js b/trunk/etherpad/src/etherpad/collab/collabroom_server.js new file mode 100644 index 0000000..ab1f844 --- /dev/null +++ b/trunk/etherpad/src/etherpad/collab/collabroom_server.js @@ -0,0 +1,359 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("execution"); +import("comet"); +import("fastJSON"); +import("cache_utils.syncedWithCache"); +import("etherpad.collab.collab_server"); +import("etherpad.collab.readonly_server"); +import("etherpad.log"); +jimport("java.util.concurrent.ConcurrentSkipListMap"); +jimport("java.util.concurrent.CopyOnWriteArraySet"); + +function onStartup() { + execution.initTaskThreadPool("collabroom_async", 1); +} + +function _doWarn(str) { + log.warn(appjet.executionId+": "+str); +} + +// deep-copies (recursively clones) an object (or value) +function _deepCopy(obj) { + if ((typeof obj) != 'object' || !obj) { + return obj; + } + var o = {}; + for(var k in obj) { + if (obj.hasOwnProperty(k)) { + var v = obj[k]; + if ((typeof v) == 'object' && v) { + o[k] = _deepCopy(v); + } + else { + o[k] = v; + } + } + } + return o; +} + +// calls func inside a global lock on the cache +function _withCache(func) { + return syncedWithCache("collabroom_server", function(cache) { + if (! cache.rooms) { + // roomName -> { connections: CopyOnWriteArraySet<connectionId>, + // type: <immutable type string> } + cache.rooms = new ConcurrentSkipListMap(); + } + if (! cache.allConnections) { + // connectionId -> connection object + cache.allConnections = new ConcurrentSkipListMap(); + } + return func(cache); + }); +} + +// accesses cache without lock +function _getCache() { + return _withCache(function(cache) { return cache; }); +} + +// if roomType is null, will only update an existing connection +// (otherwise will insert or update as appropriate) +function _putConnection(connection, roomType) { + var roomName = connection.roomName; + var connectionId = connection.connectionId; + var socketId = connection.socketId; + var data = connection.data; + + _withCache(function(cache) { + var rooms = cache.rooms; + if (! rooms.containsKey(roomName)) { + // connection refers to room that doesn't exist / is empty + if (roomType) { + rooms.put(roomName, {connections: new CopyOnWriteArraySet(), + type: roomType}); + } + else { + return; + } + } + if (roomType) { + rooms.get(roomName).connections.add(connectionId); + cache.allConnections.put(connectionId, connection); + } + else { + cache.allConnections.replace(connectionId, connection); + } + }); +} + +function _removeConnection(connection) { + _withCache(function(cache) { + var rooms = cache.rooms; + var thisRoom = connection.roomName; + var thisConnectionId = connection.connectionId; + if (rooms.containsKey(thisRoom)) { + var roomConnections = rooms.get(thisRoom).connections; + roomConnections.remove(thisConnectionId); + if (roomConnections.isEmpty()) { + rooms.remove(thisRoom); + } + } + cache.allConnections.remove(thisConnectionId); + }); +} + +function _getConnection(connectionId) { + // return a copy of the connection object + return _deepCopy(_getCache().allConnections.get(connectionId) || null); +} + +function _getConnections(roomName) { + var array = []; + + var roomObj = _getCache().rooms.get(roomName); + if (roomObj) { + var roomConnections = roomObj.connections; + var iter = roomConnections.iterator(); + while (iter.hasNext()) { + var cid = iter.next(); + var conn = _getConnection(cid); + if (conn) { + array.push(conn); + } + } + } + return array; +} + +function sendMessage(connectionId, msg) { + var connection = _getConnection(connectionId); + if (connection) { + _sendMessageToSocket(connection.socketId, msg); + if (! comet.isConnected(connection.socketId)) { + // defunct socket, disconnect (later) + execution.scheduleTask("collabroom_async", + "collabRoomDisconnectSocket", + 0, [connection.connectionId, + connection.socketId]); + } + } +} + +function _sendMessageToSocket(socketId, msg) { + var msgString = fastJSON.stringify({type: "COLLABROOM", data: msg}); + comet.sendMessage(socketId, msgString); +} + +function disconnectDefunctSocket(connectionId, socketId) { + var connection = _getConnection(connectionId); + if (connection && connection.socketId == socketId) { + removeRoomConnection(connectionId); + } +} + +function _bootSocket(socketId, reason) { + if (reason) { + _sendMessageToSocket(socketId, + {type: "DISCONNECT_REASON", reason: reason}); + } + comet.disconnect(socketId); +} + +function bootConnection(connectionId, reason) { + var connection = _getConnection(connectionId); + if (connection) { + _bootSocket(connection.socketId, reason); + removeRoomConnection(connectionId); + } +} + +function getCallbacksForRoom(roomName, roomType) { + if (! roomType) { + var room = _getCache().rooms.get(roomName); + if (room) { + roomType = room.type; + } + } + + var emptyCallbacks = {}; + emptyCallbacks.introduceUsers = + function (joiningConnection, existingConnection) {}; + emptyCallbacks.extroduceUsers = + function extroduceUsers(leavingConnection, existingConnection) {}; + emptyCallbacks.onAddConnection = function (joiningData) {}; + emptyCallbacks.onRemoveConnection = function (leavingData) {}; + emptyCallbacks.handleConnect = + function(data) { return /*userInfo or */null; }; + emptyCallbacks.clientReady = function(newConnection, data) {}; + emptyCallbacks.handleMessage = function(connection, msg) {}; + + if (roomType == collab_server.PADPAGE_ROOMTYPE) { + return collab_server.getRoomCallbacks(roomName, emptyCallbacks); + } + else if (roomType == readonly_server.PADVIEW_ROOMTYPE) { + return readonly_server.getRoomCallbacks(roomName, emptyCallbacks); + } + else { + //java.lang.System.out.println("UNKNOWN ROOMTYPE: "+roomType); + return emptyCallbacks; + } +} + +// roomName must be globally unique, just within roomType; +// data must have a userInfo.userId +function addRoomConnection(roomName, roomType, + connectionId, socketId, data) { + var callbacks = getCallbacksForRoom(roomName, roomType); + + comet.setAttribute(socketId, "connectionId", connectionId); + + bootConnection(connectionId, "userdup"); + var joiningConnection = {roomName:roomName, + connectionId:connectionId, socketId:socketId, + data:data}; + _putConnection(joiningConnection, roomType); + var connections = _getConnections(roomName); + var joiningUser = data.userInfo.userId; + + connections.forEach(function(connection) { + if (connection.socketId != socketId) { + var user = connection.data.userInfo.userId; + if (user == joiningUser) { + bootConnection(connection.connectionId, "userdup"); + } + else { + callbacks.introduceUsers(joiningConnection, connection); + } + } + }); + + callbacks.onAddConnection(data); + + return joiningConnection; +} + +function removeRoomConnection(connectionId) { + var leavingConnection = _getConnection(connectionId); + if (leavingConnection) { + var roomName = leavingConnection.roomName; + var callbacks = getCallbacksForRoom(roomName); + + _removeConnection(leavingConnection); + + _getConnections(roomName).forEach(function (connection) { + callbacks.extroduceUsers(leavingConnection, connection); + }); + + callbacks.onRemoveConnection(leavingConnection.data); + } +} + +function getConnection(connectionId) { + return _getConnection(connectionId); +} + +function updateRoomConnectionData(connectionId, data) { + var connection = _getConnection(connectionId); + if (connection) { + connection.data = data; + _putConnection(connection); + } +} + +function getRoomConnections(roomName) { + return _getConnections(roomName); +} + +function getAllRoomsOfType(roomType) { + var rooms = _getCache().rooms; + var roomsIter = rooms.entrySet().iterator(); + var array = []; + while (roomsIter.hasNext()) { + var entry = roomsIter.next(); + var roomName = entry.getKey(); + var roomStruct = entry.getValue(); + if (roomStruct.type == roomType) { + array.push(roomName); + } + } + return array; +} + +function getSocketConnectionId(socketId) { + var result = comet.getAttribute(socketId, "connectionId"); + return result && String(result); +} + +function handleComet(cometOp, cometId, msg) { + var cometEvent = cometOp; + + function requireTruthy(x, id) { + if (!x) { + _doWarn("Collab operation rejected due to missing value, case "+id); + if (messageSocketId) { + comet.disconnect(messageSocketId); + } + response.stop(); + } + return x; + } + + if (cometEvent != "disconnect" && cometEvent != "message") { + response.stop(); + } + + var messageSocketId = requireTruthy(cometId, 2); + var messageConnectionId = getSocketConnectionId(messageSocketId); + + if (cometEvent == "disconnect") { + if (messageConnectionId) { + removeRoomConnection(messageConnectionId); + } + } + else if (cometEvent == "message") { + if (msg.type == "CLIENT_READY") { + var roomType = requireTruthy(msg.roomType, 4); + var roomName = requireTruthy(msg.roomName, 11); + + var socketId = messageSocketId; + var connectionId = messageSocketId; + var clientReadyData = requireTruthy(msg.data, 12); + + var callbacks = getCallbacksForRoom(roomName, roomType); + var userInfo = + requireTruthy(callbacks.handleConnect(clientReadyData), 13); + + var newConnection = addRoomConnection(roomName, roomType, + connectionId, socketId, + {userInfo: userInfo}); + + callbacks.clientReady(newConnection, clientReadyData); + } + else { + if (messageConnectionId) { + var connection = getConnection(messageConnectionId); + if (connection) { + var callbacks = getCallbacksForRoom(connection.roomName); + callbacks.handleMessage(connection, msg); + } + } + } + } +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/collab/genimg.js b/trunk/etherpad/src/etherpad/collab/genimg.js new file mode 100644 index 0000000..04d1b3b --- /dev/null +++ b/trunk/etherpad/src/etherpad/collab/genimg.js @@ -0,0 +1,55 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sync"); +import("image"); +import("blob"); + +//jimport("java.lang.System.out.println"); + +function _cache() { + sync.callsyncIfTrue(appjet.cache, + function() { return ! appjet.cache["etherpad-genimg"]; }, + function() { appjet.cache["etherpad-genimg"] = { paths: {}}; }); + return appjet.cache["etherpad-genimg"]; +} + +function renderPath(path) { + if (_cache().paths[path]) { + //println("CACHE HIT"); + } + else { + //println("CACHE MISS"); + var regexResult = null; + var img = null; + if ((regexResult = + /solid\/([0-9]+)x([0-9]+)\/([0-9a-fA-F]{6})\.gif/.exec(path))) { + var width = Number(regexResult[1]); + var height = Number(regexResult[2]); + var color = regexResult[3]; + img = image.solidColorImageBlob(width, height, color); + } + else { + // our "broken image" image, red and partly transparent + img = image.pixelsToImageBlob(2, 2, [0x00000000, 0xffff0000, + 0xffff0000, 0x00000000], true, "gif"); + } + _cache().paths[path] = img; + } + + blob.serveBlob(_cache().paths[path]); + return true; +} diff --git a/trunk/etherpad/src/etherpad/collab/json_sans_eval.js b/trunk/etherpad/src/etherpad/collab/json_sans_eval.js new file mode 100644 index 0000000..6cbd497 --- /dev/null +++ b/trunk/etherpad/src/etherpad/collab/json_sans_eval.js @@ -0,0 +1,178 @@ +// Copyright (C) 2008 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * Parses a string of well-formed JSON text. + * + * If the input is not well-formed, then behavior is undefined, but it is + * deterministic and is guaranteed not to modify any object other than its + * return value. + * + * This does not use `eval` so is less likely to have obscure security bugs than + * json2.js. + * It is optimized for speed, so is much faster than json_parse.js. + * + * This library should be used whenever security is a concern (when JSON may + * come from an untrusted source), speed is a concern, and erroring on malformed + * JSON is *not* a concern. + * + * Pros Cons + * +-----------------------+-----------------------+ + * json_sans_eval.js | Fast, secure | Not validating | + * +-----------------------+-----------------------+ + * json_parse.js | Validating, secure | Slow | + * +-----------------------+-----------------------+ + * json2.js | Fast, some validation | Potentially insecure | + * +-----------------------+-----------------------+ + * + * json2.js is very fast, but potentially insecure since it calls `eval` to + * parse JSON data, so an attacker might be able to supply strange JS that + * looks like JSON, but that executes arbitrary javascript. + * If you do have to use json2.js with untrusted data, make sure you keep + * your version of json2.js up to date so that you get patches as they're + * released. + * + * @param {string} json per RFC 4627 + * @return {Object|Array} + * @author Mike Samuel <mikesamuel@gmail.com> + */ +var jsonParse = (function () { + var number + = '(?:-?\\b(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\\b)'; + var oneChar = '(?:[^\\0-\\x08\\x0a-\\x1f\"\\\\]' + + '|\\\\(?:[\"/\\\\bfnrt]|u[0-9A-Fa-f]{4}|x7c))'; + var string = '(?:\"' + oneChar + '*\")'; + + // Will match a value in a well-formed JSON file. + // If the input is not well-formed, may match strangely, but not in an unsafe + // way. + // Since this only matches value tokens, it does not match whitespace, colons, + // or commas. + var jsonToken = new RegExp( + '(?:false|true|null|[\\{\\}\\[\\]]' + + '|' + number + + '|' + string + + ')', 'g'); + + // Matches escape sequences in a string literal + var escapeSequence = new RegExp('\\\\(?:([^ux]|x7c)|u(.{4}))', 'g'); + + // Decodes escape sequences in object literals + var escapes = { + '"': '"', + '/': '/', + '\\': '\\', + 'b': '\b', + 'f': '\f', + 'n': '\n', + 'r': '\r', + 't': '\t', + 'x7c': '|' + }; + function unescapeOne(_, ch, hex) { + return ch ? escapes[ch] : String.fromCharCode(parseInt(hex, 16)); + } + + // A non-falsy value that coerces to the empty string when used as a key. + var EMPTY_STRING = new String(''); + var SLASH = '\\'; + + // Constructor to use based on an open token. + var firstTokenCtors = { '{': Object, '[': Array }; + + return function (json) { + // Split into tokens + var toks = json.match(jsonToken); + // Construct the object to return + var result; + var tok = toks[0]; + if ('{' === tok) { + result = {}; + } else if ('[' === tok) { + result = []; + } else { + throw new Error(tok); + } + + // If undefined, the key in an object key/value record to use for the next + // value parsed. + var key; + // Loop over remaining tokens maintaining a stack of uncompleted objects and + // arrays. + var stack = [result]; + for (var i = 1, n = toks.length; i < n; ++i) { + tok = toks[i]; + + var cont; + switch (tok.charCodeAt(0)) { + default: // sign or digit + cont = stack[0]; + cont[key || cont.length] = +(tok); + key = void 0; + break; + case 0x22: // '"' + tok = tok.substring(1, tok.length - 1); + if (tok.indexOf(SLASH) !== -1) { + tok = tok.replace(escapeSequence, unescapeOne); + } + cont = stack[0]; + if (!key) { + if (cont instanceof Array) { + key = cont.length; + } else { + key = tok || EMPTY_STRING; // Use as key for next value seen. + break; + } + } + cont[key] = tok; + key = void 0; + break; + case 0x5b: // '[' + cont = stack[0]; + stack.unshift(cont[key || cont.length] = []); + key = void 0; + break; + case 0x5d: // ']' + stack.shift(); + break; + case 0x66: // 'f' + cont = stack[0]; + cont[key || cont.length] = false; + key = void 0; + break; + case 0x6e: // 'n' + cont = stack[0]; + cont[key || cont.length] = null; + key = void 0; + break; + case 0x74: // 't' + cont = stack[0]; + cont[key || cont.length] = true; + key = void 0; + break; + case 0x7b: // '{' + cont = stack[0]; + stack.unshift(cont[key || cont.length] = {}); + key = void 0; + break; + case 0x7d: // '}' + stack.shift(); + break; + } + } + // Fail if we've got an uncompleted object. + if (stack.length) { throw new Error(); } + return result; + }; +})(); diff --git a/trunk/etherpad/src/etherpad/collab/readonly_server.js b/trunk/etherpad/src/etherpad/collab/readonly_server.js new file mode 100644 index 0000000..e367f04 --- /dev/null +++ b/trunk/etherpad/src/etherpad/collab/readonly_server.js @@ -0,0 +1,174 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("comet"); +import("ejs"); +import("etherpad.collab.ace.easysync2.{AttribPool,Changeset}"); +import("etherpad.log"); +import("etherpad.pad.activepads"); +import("etherpad.pad.model"); +import("etherpad.pad.padutils"); +import("etherpad.pad.padevents"); +import("etherpad.pro.pro_padmeta"); +import("fastJSON"); +import("fileutils.readFile"); +import("jsutils.eachProperty"); +import("etherpad.collab.server_utils.*"); +import("etherpad.collab.collabroom_server"); + +jimport("java.util.concurrent.ConcurrentHashMap"); + +jimport("java.lang.System.out.println"); + +var PADVIEW_ROOMTYPE = 'padview'; + +var _serverDebug = println;//function(x) {}; + +// "view id" is either a padId or an ro.id +function _viewIdToRoom(padId) { + return "padview/"+padId; +} + +function _roomToViewId(roomName) { + return roomName.substring(roomName.indexOf("/")+1); +} + +function getRoomCallbacks(roomName, emptyCallbacks) { + var callbacks = emptyCallbacks; + + var viewId = _roomToViewId(roomName); + + callbacks.handleConnect = function(data) { + if (data.userInfo && data.userInfo.userId) { + return data.userInfo; + } + return null; + }; + callbacks.clientReady = + function(newConnection, data) { + newConnection.data.lastRev = data.lastRev; + collabroom_server.updateRoomConnectionData(newConnection.connectionId, + newConnection.data); + }; + + return callbacks; +} + +function updatePadClients(pad) { + var padId = pad.getId(); + var roId = padIdToReadonly(padId); + + function update(connection) { + updateClient(pad, connection.connectionId); + } + + collabroom_server.getRoomConnections(_viewIdToRoom(padId)).forEach(update); + collabroom_server.getRoomConnections(_viewIdToRoom(roId)).forEach(update); +} + +// Get arrays of text lines and attribute lines for a revision +// of a pad. +function _getPadLines(pad, revNum) { + var atext; + if (revNum >= 0) { + atext = pad.getInternalRevisionAText(revNum); + } else { + atext = Changeset.makeAText("\n"); + } + + var result = {}; + result.textlines = Changeset.splitTextLines(atext.text); + result.alines = Changeset.splitAttributionLines(atext.attribs, + atext.text); + return result; +} + +function updateClient(pad, connectionId) { + var conn = collabroom_server.getConnection(connectionId); + if (! conn) { + return; + } + var lastRev = conn.data.lastRev; + while (lastRev < pad.getHeadRevisionNumber()) { + var r = ++lastRev; + var author = pad.getRevisionAuthor(r); + var lines = _getPadLines(pad, r-1); + var wirePool = new AttribPool(); + var forwards = pad.getRevisionChangeset(r); + var backwards = Changeset.inverse(forwards, lines.textlines, + lines.alines, pad.pool()); + var forwards2 = Changeset.moveOpsToNewPool(forwards, pad.pool(), + wirePool); + var backwards2 = Changeset.moveOpsToNewPool(backwards, pad.pool(), + wirePool); + + function revTime(r) { + var date = pad.getRevisionDate(r); + var s = Math.floor((+date)/1000); + //java.lang.System.out.println("time "+r+": "+s); + return s; + } + + var msg = {type:"NEW_CHANGES", newRev:r, + changeset: forwards2, + changesetBack: backwards2, + apool: wirePool.toJsonable(), + author: author, + timeDelta: revTime(r) - revTime(r-1) }; + collabroom_server.sendMessage(connectionId, msg); + } + conn.data.lastRev = pad.getHeadRevisionNumber(); + collabroom_server.updateRoomConnectionData(connectionId, conn.data); +} + +function sendMessageToPadConnections(pad, msg) { + var padId = pad.getId(); + var roId = padIdToReadonly(padId); + + function update(connection) { + collabroom_server.sendMessage(connection.connectionId, msg); + } + + collabroom_server.getRoomConnections(_viewIdToRoom(padId)).forEach(update); + collabroom_server.getRoomConnections(_viewIdToRoom(roId)).forEach(update); +} + +function updateUserInfo(pad, userInfo) { + var msg = { type:"NEW_AUTHORDATA", + author: userInfo.userId, + data: {} }; + var hasData = false; + if ((typeof (userInfo.colorId)) == "number") { + msg.data.colorId = userInfo.colorId; + hasData = true; + } + if (userInfo.name) { + msg.data.name = userInfo.name; + hasData = true; + } + if (hasData) { + sendMessageToPadConnections(pad, msg); + } +} + +function broadcastNewRevision(pad, revObj) { + var msg = { type:"NEW_SAVEDREV", + savedRev: revObj }; + + delete revObj.ip; // we try not to share info like IP addresses on slider + + sendMessageToPadConnections(pad, msg); +} diff --git a/trunk/etherpad/src/etherpad/collab/server_utils.js b/trunk/etherpad/src/etherpad/collab/server_utils.js new file mode 100644 index 0000000..ece3aea --- /dev/null +++ b/trunk/etherpad/src/etherpad/collab/server_utils.js @@ -0,0 +1,204 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("comet"); +import("ejs"); +import("etherpad.collab.ace.easysync2.{AttribPool,Changeset}"); +import("etherpad.log"); +import("etherpad.pad.activepads"); +import("etherpad.pad.model"); +import("etherpad.pad.padutils"); +import("etherpad.pad.padevents"); +import("etherpad.pro.pro_padmeta"); +import("fastJSON"); +import("fileutils.readFile"); +import("jsutils.eachProperty"); + +jimport("java.util.Random"); +jimport("java.lang.System"); + +import("etherpad.collab.collab_server"); +// importClass(java.util.Random); +// importClass(java.lang.System); + +var _serverDebug = function() {}; +var _dmesg = function() { System.out.println(arguments[0]+""); }; + +/// Begin readonly/padId conversion code +/// TODO: refactor into new file? +var _baseRandomNumber = 0x123123; // keep this number seekrit + +function _map(array, func) { + for(var i=0; i<array.length; i++) { + array[i] = func(array[i]); + } + return array; +} + +function parseUrlId(readOnlyIdOrLocalPadId) { + var localPadId; + var viewId; + var isReadOnly; + var roPadId; + var globalPadId; + if(isReadOnlyId(readOnlyIdOrLocalPadId)) { + isReadOnly = true; + globalPadId = readonlyToPadId(readOnlyIdOrLocalPadId); + localPadId = padutils.globalToLocalId(globalPadId); + var globalPadIdCheck = padutils.getGlobalPadId(localPadId); + if (globalPadId != globalPadIdCheck) { + // domain doesn't match + response.forbid(); + } + roPadId = readOnlyIdOrLocalPadId; + viewId = roPadId; + } + else { + isReadOnly = false; + localPadId = readOnlyIdOrLocalPadId; + globalPadId = padutils.getGlobalPadId(localPadId); + viewId = globalPadId; + roPadId = padIdToReadonly(globalPadId); + } + + return {localPadId:localPadId, viewId:viewId, isReadOnly:isReadOnly, + roPadId:roPadId, globalPadId:globalPadId}; +} + +function isReadOnlyId(str) { + return str.indexOf("ro.") == 0; +} + +/* + for now, we just make it 'hard to guess' + TODO: make it impossible to find read/write page through hash +*/ +function readonlyToPadId (readOnlyHash) { + + // readOnly hashes must start with 'ro-' + if(!isReadOnlyId(readOnlyHash)) return null; + else { + readOnlyHash = readOnlyHash.substring(3, readOnlyHash.length); + } + + // convert string to series of numbers between 1 and 64 + var result = _strToArray(readOnlyHash); + + var sum = result.pop(); + // using a secret seed to util.random, transform each number using + and % + var seed = _baseRandomNumber + sum; + var rand = new Random(seed); + + _map(result, function(elem) { + return ((64 + elem - rand.nextInt(64)) % 64); + }); + + // convert array of numbers back to a string + return _arrayToStr(result); +} + +/* + Temporary code. see comment at readonlyToPadId. +*/ +function padIdToReadonly (padid) { + var result = _strToArray(padid); + var sum = 0; + + if(padid.length > 1) { + for(var i=0; i<result.length; i++) { + sum = (sum + result[i] + 1) % 64; + } + } else { + sum = 64; + } + + var seed = _baseRandomNumber + sum; + var rand = new Random(seed); + + _map(result, function(elem) { + var randnum = rand.nextInt(64); + return ((elem + randnum) % 64); + }); + + result.push(sum); + return "ro." + _arrayToStr(result); +} + +// little reversable string encoding function +// 0-9 are the numbers 0-9 +// 10-35 are the uppercase letters A-Z +// 36-61 are the lowercase letters a-z +// 62 are all other characters +function _strToArray(str) { + var result = new Array(str.length); + for(var i=0; i<str.length; i++) { + result[i] = str.charCodeAt(i); + + if (_between(result[i], '0'.charCodeAt(0), '9'.charCodeAt(0))) { + result[i] -= '0'.charCodeAt(0); + } + else if(_between(result[i], 'A'.charCodeAt(0), 'Z'.charCodeAt(0))) { + result[i] -= 'A'.charCodeAt(0); // A becomes 0 + result[i] += 10; // A becomes 10 + } + else if(_between(result[i], 'a'.charCodeAt(0), 'z'.charCodeAt(0))) { + result[i] -= 'a'.charCodeAt(0); // a becomes 0 + result[i] += 36; // a becomes 36 + } else if(result[i] == '$'.charCodeAt(0)) { + result[i] = 62; + } else { + result[i] = 63; // if not alphanumeric or $, we default to 63 + } + } + return result; +} + +function _arrayToStr(array) { + var result = ""; + for(var i=0; i<array.length; i++) { + if(_between(array[i], 0, 9)) { + result += String.fromCharCode(array[i] + '0'.charCodeAt(0)); + } + else if(_between(array[i], 10, 35)) { + result += String.fromCharCode(array[i] - 10 + 'A'.charCodeAt(0)); + } + else if(_between(array[i], 36, 61)) { + result += String.fromCharCode(array[i] - 36 + 'a'.charCodeAt(0)); + } + else if(array[i] == 62) { + result += "$"; + } else { + result += "-"; + } + } + return result; +} + +function _between(charcode, start, end) { + return charcode >= start && charcode <= end; +} + +/* a short little testing function, converts back and forth */ +// function _testEncrypt(str) { +// var encrypted = padIdToReadonly(str); +// var decrypted = readonlyToPadId(encrypted); +// _dmesg(str + " " + encrypted + " " + decrypted); +// if(decrypted != str) { +// _dmesg("ERROR: " + str + " and " + decrypted + " do not match"); +// } +// } + +// _testEncrypt("testing$"); diff --git a/trunk/etherpad/src/etherpad/control/aboutcontrol.js b/trunk/etherpad/src/etherpad/control/aboutcontrol.js new file mode 100644 index 0000000..9d77142 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/aboutcontrol.js @@ -0,0 +1,263 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("email.sendEmail"); +import("funhtml.*", "stringutils.*"); +import("netutils"); +import("execution"); + +import("etherpad.utils.*"); +import("etherpad.log"); +import("etherpad.globals.*"); +import("etherpad.quotas"); +import("etherpad.sessions.getSession"); +import("etherpad.store.eepnet_trial"); +import("etherpad.store.checkout"); +import("etherpad.store.eepnet_checkout"); + +jimport("java.lang.System.out.println"); + +//---------------------------------------------------------------- + +function render_product() { + if (request.params.from) { response.redirect(request.path); } + renderFramed("about/product_body.ejs"); +} + +function render_faq() { + renderFramed("about/faq_body.ejs", { + LI: LI, + H2: H2, + A: A, + html: html + }); +} + +function render_pne_faq() { + renderFramed("about/pne-faq.ejs"); +} + +function render_company() { + renderFramed("about/company_body.ejs"); +} + +function render_contact() { + renderFramed("about/contact_body.ejs"); +} + +function render_privacy() { + renderFramed("about/privacy_body.ejs"); +} + +function render_tos() { + renderFramed("about/tos_body.ejs"); +} + +function render_testimonials() { + renderFramed("about/testimonials.ejs"); +} + +function render_appjet() { + response.redirect("/ep/blog/posts/etherpad-and-appjet"); +// renderFramed("about/appjet_body.ejs"); +} + +function render_screencast() { + if (request.params.from) { response.redirect(request.path); } + var screencastUrl; +// if (isProduction()) { + screencastUrl = encodeURIComponent("http://etherpad.s3.amazonaws.com/epscreencast800x600.flv"); +// } else { +// screencastUrl = encodeURIComponent("/static/flv/epscreencast800x600.flv"); +// } + renderFramed("about/screencast_body.ejs", {screencastUrl: screencastUrl}); +} + +function render_forums() { + renderFramed("about/forums_body.ejs"); +} + +function render_blog() { + renderFramed("about/blog_body.ejs"); +} + +function render_really_real_time() { + renderFramed("about/simultaneously.ejs"); +} + +function render_simultaneously() { + renderFramed("about/simultaneously.ejs"); +} + +//---------------------------------------------------------------- +// pricing +//---------------------------------------------------------------- + +function render_pricing() { + renderFramed("about/pricing.ejs", { + trialDays: eepnet_trial.getTrialDays(), + costPerUser: checkout.dollars(eepnet_checkout.COST_PER_USER) + }); +} + +function render_pricing_free() { + renderFramed("about/pricing_free.ejs", { + maxUsersPerPad: quotas.getMaxSimultaneousPadEditors() + }); +} + +function render_pricing_eepnet() { + renderFramed("about/pricing_eepnet.ejs", { + trialDays: eepnet_trial.getTrialDays(), + costPerUser: checkout.dollars(eepnet_checkout.COST_PER_USER) + }); +} + +function render_pricing_pro() { + renderFramed("about/pricing_pro.ejs", {}); +} + +function render_eepnet_pricing_contact_post() { + response.setContentType("text/plain; charset=utf-8"); + var data = {}; + var fields = ['firstName', 'lastName', 'email', 'orgName', + 'jobTitle', 'phone', 'estUsers', 'industry']; + + if (!getSession().pricingContactData) { + getSession().pricingContactData = {}; + } + + function err(m) { + response.write(m); + response.stop(); + } + + fields.forEach(function(f) { + getSession().pricingContactData[f] = request.params[f]; + }); + + fields.forEach(function(f) { + data[f] = request.params[f]; + if (!(data[f] && (data[f].length > 0))) { + err("All fields are required."); + } + }); + + if (!isValidEmail(data.email)) { + err("Error: Invalid Email"); + } + + // log this data to a file + fields.ip = request.clientAddr; + fields.sessionReferer = getSession().initialReferer; + log.custom("eepnet_pricing_inquiry", fields); + + // submit web2lead + var ref = getSession().initialReferer; + var googleQuery = extractGoogleQuery(ref); + var wlparams = { + oid: "00D80000000b7ey", + first_name: data.firstName, + last_name: data.lastName, + email: data.email, + company: data.orgName, + title: data.jobTitle, + phone: data.phone, + '00N80000003FYtG': data.estUsers, + '00N80000003FYto': ref, + '00N80000003FYuI': googleQuery, + lead_source: 'EEPNET Pricing Inquiry', + industry: data.industry, + retURL: 'http://'+request.host+'/ep/store/salesforce-web2lead-ok' + }; + + var result = netutils.urlPost( + "http://www.salesforce.com/servlet/servlet.WebToLead?encoding=UTF-8", + wlparams, {}); + + // now send an email sales notification + var hostname = ipToHostname(request.clientAddr) || "unknown"; + var subject = 'EEPNET Pricing Inquiry: '+data.email+' / '+hostname; + var body = [ + "", "This is an automated email.", "", + data.firstName+" "+data.lastName+" ("+data.orgName+") has inquired about EEPNET pricing.", + "", + "This record has automatically been added to SalesForce. See the salesforce lead page for more details.", + "", "Session Referer: "+ref, "" + ].join("\n"); + var toAddr = 'sales@pad.spline.inf.fu-berlin.de'; + if (isTestEmail(data.email)) { + toAddr = 'blackhole@appjet.com'; + } + sendEmail(toAddr, 'sales@pad.spline.inf.fu-berlin.de', subject, {}, body); + + // all done! + response.write("OK"); +} + +function render_pricing_interest_signup() { + response.setContentType('text/plain; charset=utf-8'); + + var email = request.params.email; + var interestedNet = request.params.interested_net; + var interestedHosted = request.params.interested_hosted; + + if (!isValidEmail(email)) { + response.write("Error: Invalid Email"); + response.stop(); + } + + log.custom("pricing_interest", + {email: email, + net: interestedNet, + hosted: interestedHosted}); + + response.write('OK'); +} + +function render_pricing_eepnet_users() { + renderFramed('about/pricing_eepnet_users.ejs', {}); +} + +function render_pricing_eepnet_support() { + renderFramed('about/pricing_eepnet_support.ejs', {}); +} + + +//------------------------------------------------------------ +// survey + +function render_survey() { + var id = request.params.id; + log.custom("pro-user-survey", { surveyProAccountId: (id || "unknown") }); + response.redirect("http://www.surveymonkey.com/s.aspx?sm=yT3ALP0pb_2fP_2bHtcfzvpkXQ_3d_3d"); +} + + +//------------------------------------------------------------ + +import("etherpad.billing.billing"); + +function render_testbillingnotify() { + var ret = billing.handlePaypalNotification(); + if (ret.status == 'completion') { + // do something with purchase ret.purchaseInfo + } else if (ret.status != 'redundant') { + java.lang.System.out.println("Whoa error: "+ret.toSource()); + } + response.write("ok"); +} + diff --git a/trunk/etherpad/src/etherpad/control/admincontrol.js b/trunk/etherpad/src/etherpad/control/admincontrol.js new file mode 100644 index 0000000..02f6428 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/admincontrol.js @@ -0,0 +1,1471 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("fastJSON"); +import("netutils"); +import("funhtml.*"); +import("stringutils.{html,sprintf,startsWith,md5}"); +import("jsutils.*"); +import("sqlbase.sqlbase"); +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); +import("varz"); +import("comet"); +import("dispatch.{Dispatcher,PrefixMatcher,DirMatcher,forward}"); + +import("etherpad.billing.team_billing"); +import("etherpad.globals.*"); +import("etherpad.utils.*"); +import("etherpad.licensing"); +import("etherpad.sessions.getSession"); +import("etherpad.sessions"); +import("etherpad.statistics.statistics"); +import("etherpad.log"); +import("etherpad.admin.shell"); +import("etherpad.usage_stats.usage_stats"); +import("etherpad.control.blogcontrol"); +import("etherpad.control.pro_beta_control"); +import("etherpad.control.statscontrol"); +import("etherpad.statistics.exceptions"); +import("etherpad.store.checkout"); + +import("etherpad.pad.activepads"); +import("etherpad.pad.model"); +import("etherpad.pad.padutils"); +import("etherpad.pad.dbwriter"); +import("etherpad.collab.collab_server"); + +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_utils"); +import("etherpad.pro.domains"); + +jimport("java.lang.System.out.println"); + +jimport("net.appjet.oui.cometlatencies"); +jimport("net.appjet.oui.appstats"); + + +//---------------------------------------------------------------- + +function _isAuthorizedAdmin() { + if (!isProduction()) { + return true; + } + return (getSession().adminAuth === true); +} + +var _mainLinks = [ + ['exceptions', 'Exceptions Monitor'], + ['usagestats/', 'Usage Stats'], + ['padinspector', 'Pad Inspector'], + ['dashboard', 'Dashboard'], + ['eepnet-licenses', 'EEPNET Licenses'], + ['config', 'appjet.config'], + ['shell', 'Shell'], + ['timings', 'timing data'], + ['broadcast-message', 'Pad Broadcast'], +// ['analytics', 'Google Analytics'], + ['varz', 'varz'], + ['genlicense', 'Manually generate a license key'], + ['flows', 'Flows (warning: slow)'], + ['diagnostics', 'Pad Connection Diagnostics'], + ['cachebrowser', 'Cache Browser'], + ['pne-tracker', 'PNE Tracking Stats'], + ['reload-blog-db', 'Reload blog DB'], + ['pro-domain-accounts', 'Pro Domain Accounts'], + ['beta-valve', 'Beta Valve'], + ['reset-subscription', "Reset Subscription"] +]; + +function onRequest(name) { + if (name == "auth") { + return; + } + if (!_isAuthorizedAdmin()) { + getSession().cont = request.path; + response.redirect('/ep/admin/auth'); + } + + var disp = new Dispatcher(); + disp.addLocations([ + [PrefixMatcher('/ep/admin/usagestats/'), forward(statscontrol)] + ]); + + return disp.dispatch(); +} + +function _commonHead() { + return HEAD(STYLE( + "html {font-family:Verdana,Helvetica,sans-serif;}", + "body {padding: 2em;}" + )); +} + +//---------------------------------------------------------------- + +function render_auth() { + var cont = getSession().cont; + if (getSession().message) { + response.write(DIV(P(B(getSession().message)))); + delete getSession().message; + } + if (request.method == "GET") { + response.write(FORM({method: "POST", action: request.path}, + P("Are you an admin?"), + LABEL("Password:"), + INPUT({type: "password", name: "password", value: ""}), + INPUT({type: "submit", value: "submit"}) + )); + } + if (request.method == "POST") { + var pass = request.params.password; + if (pass === appjet.config['etherpad.adminPass']) { + getSession().adminAuth = true; + if (cont) { + response.redirect(cont); + } else { + response.redirect("/ep/admin/main"); + } + } else { + getSession().message = "Bad Password."; + response.redirect(request.path); + } + } +} + +function render_main() { + var div = DIV(); + + div.push(A({href: "/"}, html("«"), " home")); + div.push(H1("Admin")); + + _mainLinks.forEach(function(l) { + div.push(DIV(A({href: l[0]}, l[1]))); + }); + if (sessions.isAnEtherpadAdmin()) { + div.push(P(A({href: "/ep/admin/setadminmode?v=false"}, + "Exit Admin Mode"))); + } + else { + div.push(P(A({href: "/ep/admin/setadminmode?v=true"}, + "Enter Admin Mode"))); + } + response.write(HTML(_commonHead(), BODY(div))); +} + +//---------------------------------------------------------------- + +function render_config() { + + vars = []; + eachProperty(appjet.config, function(k,v) { + vars.push(k); + }); + + vars.sort(); + + response.setContentType('text/plain; charset=utf-8'); + vars.forEach(function(v) { + response.write("appjet.config."+v+" = "+appjet.config[v]+"\n"); + }); +} + +//---------------------------------------------------------------- + +function render_test() { + response.setContentType("text/plain"); + response.write(Packages.net.appjet.common.util.ExpiringMapping + "\n"); + var m = new Packages.net.appjet.common.util.ExpiringMapping(10 * 1000); + response.write(m.toString() + "\n"); + m.get("test"); + return; + response.write(m.toString()); +} + +function render_dashboard() { + var body = BODY(); + body.push(A({href: '/ep/admin/'}, html("« Admin"))); + body.push(H1({style: "border-bottom: 1px solid black;"}, "Dashboard")); + + /* + body.push(H2({style: "color: #226; font-size: 1em;"}, "License")); + var license = licensing.getLicense(); + body.push(P(TT(" Licensed To (name): "+license.personName))); + body.push(P(TT(" Licensed To (organization): "+license.organizationName))); + body.push(P(TT(" Software Edition: "+license.editionName))); + var quota = ((license.userQuota > 0) ? license.userQuota : 'unlimited'); + body.push(P(TT(" User Quota: "+quota))); + var expires = (license.expiresDate ? (license.expiresDate.toString()) : 'never'); + body.push(P(TT(" Expires: "+expires))); + */ + + /* + body.push(H2({style: "color: #226; font-size: 1em;"}, "Active User Quota")); + + var activeUserCount = licensing.getActiveUserCount(); + var activeUserQuota = licensing.getActiveUserQuota(); + var activeUserWindowStart = licensing.getActiveUserWindowStart(); + + body.push(P(TT(" Since ", B(activeUserWindowStart.toString()), ", ", + "you have used ", B(activeUserCount), " of ", B(activeUserQuota), + " active users."))); +*/ + body.push(H2({style: "color: #226; font-size: 1em;"}, "Uptime")); + body.push(P({style: "margin-left: 25px;"}, "Server running for "+renderServerUptime()+".")) + + body.push(H2({style: "color: #226; font-size: 1em;"}, "Response codes")); + body.push(renderResponseCodes()); + + body.push(H2({style: "color: #226; font-size: 1em;"}, "Comet Connections")); + body.push(renderPadConnections()); + + body.push(H2({style: "color: #226; font-size: 1em;"}, "Comet Stats")); + body.push(renderCometStats()); + + body.push(H2({style: "color: #226; font-size: 1em;"}, "Recurring revenue, monthly")); + body.push(renderRevenueStats()); + + response.write(HTML(_commonHead(), body)); +} + +// Note: This function is called by the PNE dashboard (pro_admin_control.js)! Be careful. +function renderPadConnections() { + var d = DIV(); + var lastCount = cometlatencies.lastCount(); + + if (lastCount.isDefined()) { + var countMap = {}; + Array.prototype.map.call(lastCount.get().elements().collect().toArray().unbox( + java.lang.Class.forName("java.lang.Object")), + function(x) { + countMap[x._1()] = x._2(); + }); + var totalConnected = 0; + var ul = UL(); + eachProperty(countMap, function(k,v) { + ul.push(LI(k+": "+v)); + if (/^\d+$/.test(v)) { + totalConnected += Number(v); + } + }); + ul.push(LI(B("Total: ", totalConnected))); + d.push(ul); + } else { + d.push("Still collecting data... check back in a minute."); + } + return d; +} + +// Note: This function is called by the PNE dashboard (pro_admin_control.js)! Be careful. +function renderCometStats() { + var d = DIV(); + var lastStats = cometlatencies.lastStats(); + var lastCount = cometlatencies.lastCount(); + + + if (lastStats.isDefined()) { + d.push(P("Realtime transport latency percentiles (microseconds):")); + var ul = UL(); + lastStats.map(scalaF1(function(s) { + ['50', '90', '95', '99', 'max'].forEach(function(id) { + var fn = id; + if (id != "max") { + fn = ("p"+fn); + id = id+"%"; + } + ul.push(LI(id, ": <", s[fn](), html("µ"), "s")); + }); + })); + d.push(ul); + } else { + d.push(P("Still collecting data... check back in a minutes.")); + } + + /* ["p50", "p90", "p95", "p99", "max"].forEach(function(id) { + ul.push(LI(B( + + return DIV(P(sprintf("50%% %d\t90%% %d\t95%% %d\t99%% %d\tmax %d", + s.p50(), s.p90(), s.p95(), s.p99(), s.max())), + P(sprintf("%d total messages", s.count()))); + }})).get();*/ + + + return d; +} + +// Note: This function is called by the PNE dashboard (pro_admin_control.js)! Be careful. +function renderResponseCodes() { + var statusCodeFrequencyNames = ["minute", "hour", "day", "week"]; + var data = { }; + var statusCodes = appstats.stati(); + for (var i = 0; i < statusCodes.length; ++i) { + var name = statusCodeFrequencyNames[i]; + var map = statusCodes[i]; + map.foreach(scalaF1(function(pair) { + if (! (pair._1() in data)) data[pair._1()] = {}; + var scmap = data[pair._1()]; + scmap[name] = pair._2().count(); + })); + }; + var stats = TABLE({id: "responsecodes-table", style: "margin-left: 25px;", + border: 1, cellspacing: 0, cellpadding: 4}, + TR.apply(TR, statusCodeFrequencyNames.map(function(name) { + return TH({colspan: 2}, "Last", html(" "), name); + }))); + var sortedStati = []; + eachProperty(data, function(k) { + sortedStati.push(k); + }); + sortedStati.sort(); + sortedStati.forEach(function(k, i) { // k is status code. + var row = TR(); + statusCodeFrequencyNames.forEach(function(name) { + row.push(TD({style: 'width: 2em;'}, data[k][name] ? k+":" : "")); + row.push(TD(data[k][name] ? data[k][name] : "")); + }); + stats.push(row); + }); + return stats; +} + +// Note: This function is called by the PNE dashboard (pro_admin_control.js)! Be careful. +function renderServerUptime() { + var labels = ["seconds", "minutes", "hours", "days"]; + var ratios = [60, 60, 24]; + var time = appjet.uptime / 1000; + var pos = 0; + while (pos < ratios.length && time / ratios[pos] > 1.1) { + time = time / ratios[pos]; + pos++; + } + return sprintf("%.1f %s", time, labels[pos]); +} + +function renderRevenueStats() { + var subs = team_billing.getAllSubscriptions(); + var total = 0; + var totalUsers = 0; + subs.forEach(function(sub) { + var users = team_billing.getMaxUsers(sub.customer); + var cost = team_billing.calculateSubscriptionCost(users, sub.coupon); + if (cost > 0) { + totalUsers += users; + total += cost; + } + }); + return "US $"+checkout.dollars(total)+", from "+subs.length+" domains and "+totalUsers+" users."; +} + +//---------------------------------------------------------------- +// Broadcasting Messages +//---------------------------------------------------------------- + +function render_broadcast_message_get() { + var body = BODY(FORM({action: request.path, method: 'post'}, + H3('Broadcast Message to All Active Pad Clients:'), + TEXTAREA({name: 'msgtext', style: 'width: 100%; height: 100px;'}), + H3('JavaScript code to be eval()ed on client (optional, be careful!): '), + TEXTAREA({name: 'jscode', style: 'width: 100%; height: 100px;'}), + INPUT({type: 'submit', value: 'Broadcast Now'}))); + response.write(HTML(body)); +} + +function render_broadcast_message_post() { + var msgText = request.params.msgtext; + var jsCode = request.params.jscode; + if (!(msgText || jsCode)) { + response.write("No mesage text or jscode specified."); + response.stop(); + return; + } + collab_server.broadcastServerMessage({ + type: 'NOTICE', + text: msgText, + js: jsCode + }); + response.write(HTML(BODY(P("OK"), P(A({href: request.path}, "back"))))); +} + +function render_shell() { + shell.handleRequest(); +} + +//---------------------------------------------------------------- +// pad inspector +//---------------------------------------------------------------- + +function _getPadUrl(globalPadId) { + var superdomain = pro_utils.getRequestSuperdomain(); + var domain; + if (padutils.isProPadId(globalPadId)) { + var domainId = padutils.getDomainId(globalPadId); + domain = domains.getDomainRecord(domainId).subDomain + + '.' + superdomain; + } + else { + domain = superdomain; + } + var localId = padutils.globalToLocalId(globalPadId); + return "http://"+httpHost(domain)+"/"+localId; +} + +function render_padinspector_get() { + var padId = request.params.padId; + if (!padId) { + response.write(FORM({action: request.path, method: 'get', style: 'border: 1px solid #ccc; background-color: #eee; padding: .2em 1em;'}, + P("Pad Lookup: ", + INPUT({name: 'padId', value: '<enter pad id>'}), + INPUT({type: 'submit'})))); + + // show recently active pads; the number of them may vary; lots of + // activity in a pad will push others off the list + response.write(H3("Recently Active Pads:")); + var recentlyActiveTable = TABLE({cellspacing: 0, cellpadding: 6, border: 1, + style: 'font-family: monospace;'}); + var recentPads = activepads.getActivePads(); + recentPads.forEach(function (info) { + var time = info.timestamp; // number + var pid = info.padId; + model.accessPadGlobal(pid, function(pad) { + if (pad.exists()) { + var numRevisions = pad.getHeadRevisionNumber(); + var connected = collab_server.getNumConnections(pad); + recentlyActiveTable.push( + TR(TD(B(pid)), + TD({style: 'font-style: italic;'}, timeAgo(time)), + TD(connected+" connected"), + TD(numRevisions+" revisions"), + TD(A({href: qpath({padId: pid, revtext: "HEAD"})}, "HEAD")), + TD(A({href: qpath({padId: pid})}, "inspect")), + TD(A({href: qpath({padId: pid, snoop: 1})}, "snoop")) + )); + } + }, "r"); + }); + response.write(recentlyActiveTable); + response.stop(); + } + if (startsWith(padId, '/')) { + padId = padId.substr(1); + } + if (request.params.snoop) { + sessions.setIsAnEtherpadAdmin(true); + response.redirect(_getPadUrl(padId)); + } + if (request.params.setsupportstimeslider) { + var v = (String(request.params.setsupportstimeslider).toLowerCase() == + 'true'); + model.accessPadGlobal(padId, function(pad) { + pad.setSupportsTimeSlider(v); + }); + response.write("on pad "+padId+": setSupportsTimeSlider("+v+")"); + response.stop(); + } + model.accessPadGlobal(padId, function(pad) { + if (! pad.exists()) { + response.write("Pad not found: /"+padId); + } + else { + var headRev = pad.getHeadRevisionNumber(); + var div = DIV({style: 'font-family: monospace;'}); + + if (request.params.revtext) { + var i; + if (request.params.revtext == "HEAD") { + i = headRev; + } else { + i = Number(request.params.revtext); + } + var infoObj = {}; + div.push(H2(A({href: request.path}, "PadInspector"), + ' > ', A({href: request.path+'?padId='+padId}, "/"+padId), + ' > ', "Revision ", i, "/", headRev, + SPAN({style: 'color: #949;'}, ' [ ', pad.getRevisionDate(i).toString(), ' ] '))); + div.push(H3("Browse Revisions: ", + ((i > 0) ? A({id: 'previous', href: qpath({revtext: (i-1)})}, '<< previous') : ''), + ' ', + ((i < pad.getHeadRevisionNumber()) ? A({id: 'next', href: qpath({revtext:(i+1)})}, 'next >>') : '')), + DIV({style: 'padding: 1em; border: 1px solid #ccc;'}, + pad.getRevisionText(i, infoObj))); + if (infoObj.badLastChar) { + div.push(P("Bad last character of text (not newline): "+infoObj.badLastChar)); + } + } else if (request.params.dumpstorage) { + div.push(P(collab_server.dumpStorageToString(pad))); + } else if (request.params.showlatest) { + div.push(P(pad.text())); + } else { + div.push(H2(A({href: request.path}, "PadInspector"), ' > ', "/"+padId)); + // no action + div.push(P(A({href: qpath({revtext: 'HEAD'})}, 'HEAD='+headRev))); + div.push(P(A({href: qpath({dumpstorage: 1})}, 'dumpstorage'))); + var supportsTimeSlider = pad.getSupportsTimeSlider(); + if (supportsTimeSlider) { + div.push(P(A({href: qpath({setsupportstimeslider: 'false'})}, 'hide slider'))); + } + else { + div.push(P(A({href: qpath({setsupportstimeslider: 'true'})}, 'show slider'))); + } + } + } + + var script = SCRIPT({type: 'text/javascript'}, html([ + '$(document).keydown(function(e) {', + ' var h = undefined;', + ' if (e.keyCode == 37) { h = $("#previous").attr("href"); }', + ' if (e.keyCode == 39) { h = $("#next").attr("href"); }', + ' if (h) { window.location.href = h; }', + '});' + ].join('\n'))); + + response.write(HTML( + HEAD(SCRIPT({type: 'text/javascript', src: '/static/js/jquery-1.3.2.js?'+(+(new Date))})), + BODY(div, script))); + }, "r"); +} + +function render_analytics() { + response.redirect("https://www.google.com/analytics/reporting/?reset=1&id=12611622"); +} + +//---------------------------------------------------------------- +// eepnet license display +//---------------------------------------------------------------- + +function render_eepnet_licenses() { + var data = sqlobj.selectMulti('eepnet_signups', {}, {orderBy: 'date'}); + var t = TABLE({border: 1, cellspacing: 0, cellpadding: 2}); + var cols = ['date','email','orgName','firstName','lastName', 'jobTitle','phone','estUsers']; + data.forEach(function(x) { + var tr = TR(); + cols.forEach(function(colname) { + tr.push(TD(x[colname])); + }); + t.push(tr); + }); + response.write(HTML(BODY({style: 'font-family: monospace;'}, t))); +} + +//---------------------------------------------------------------- +// pad integrity +//---------------------------------------------------------------- + +/*function render_changesettest_get() { + var nums = [0, 1, 2, 3, 0xfffffff, 0x02345678, 4]; + var str = Changeset.numberArrayToString(nums); + var result = Changeset.numberArrayFromString(str); + var resultArray = result[0]; + var remainingString = result[1]; + var bad = false; + if (remainingString) { + response.write(P("remaining string length is: "+remainingString.length)); + bad = true; + } + if (nums.length != resultArray.length) { + response.write(P("length mismatch: "+nums.length+" / "+resultArray.length)); + bad = true; + } + response.write(P(nums[2])); + for(var i=0;i<nums.length;i++) { + var a = nums[i]; + var b = resultArray[i]; + if (a !== b) { + response.write(P("mismatch at element "+i+": "+a+" / "+b)); + bad = true; + } + } + if (! bad) { + response.write("SUCCESS"); + } +}*/ + +///////// + +function render_appendtest() { + var padId = request.params.padId; + var mode = request.params.mode; + var text = request.params.text; + + model.accessPadGlobal(padId, function(pad) { + if (mode == "append") { + collab_server.appendPadText(pad, text); + } + else if (mode == "replace") { + collab_server.setPadText(pad, text); + } + }); +} + +//function render_flushall() { +// dbwriter.writeAllToDB(null, true); +// response.write("OK"); +//} + +//function render_flushpad() { +// var padId = request.params.padId; +// model.accessPadGlobal(padId, function(pad) { +// dbwriter.writePad(pad, true); +// }); +// response.write("OK"); +//} + +/*function render_foo() { + locking.doWithPadLock("CAT", function() { + sqlbase.createJSONTable("STUFF"); + sqlbase.putJSON("STUFF", "dogs", {very:"bad"}); + response.write(sqlbase.getJSON("STUFF", "dogs")); // {very:"bad"} + response.write(','); + response.write(sqlbase.getJSON("STUFF", "cats")); // undefined + response.write("<br/>"); + + sqlbase.createStringArrayTable("SEQUENCES"); + sqlbase.putStringArrayElement("SEQUENCES", "fibo", 0, "1"); + sqlbase.putStringArrayElement("SEQUENCES", "fibo", 1, "1"); + sqlbase.putStringArrayElement("SEQUENCES", "fibo", 2, "2"); + sqlbase.putStringArrayElement("SEQUENCES", "fibo", 3, "3"); + sqlbase.putStringArrayElement("SEQUENCES", "fibo", 4, "5"); + sqlbase.putStringArrayElement("SEQUENCES", "fibo", 30, "number30"); + sqlbase.putStringArrayElement("SEQUENCES", "fibo", 29, "number29"); + sqlbase.deleteStringArrayElement("SEQUENCES", "fibo", 29); + sqlbase.putConsecutiveStringArrayElements("SEQUENCES", "fibo", 19, [19,20,21,22]); + var a = []; + for(var i=0;i<31;i++) { + a.push(sqlbase.getStringArrayElement("SEQUENCES", "fibo", i)); + } + response.write(a.join(',')); // 1,1,2,3,5,,, ... 19,20,21,22, ... ,,,number30 + }); +}*/ + +function render_timings() { + var timer = Packages.net.appjet.ajstdlib.timer; + var opnames = timer.getOpNames(); + + response.write(P(A({href: '/ep/admin/timingsreset'}, "reset all"))); + + var t = TABLE({border: 1, cellspacing: 0, cellpadding: 3, style: 'font-family: monospace;'}); + t.push(TR(TH("operation"), + TH("sample_count"), + TH("total_ms"), + TH("avg_ms"))); + + function r(x) { + return sprintf("%09.2f", x); + } + var rows = []; + for (var i = 0; i < opnames.length; i++) { + var stats = timer.getStats(opnames[i]); + rows.push([String(opnames[i]), + Math.floor(stats[0]), + stats[1], + stats[2]]); + } + + var si = Number(request.params.sb || 0); + + rows.sort(function(a,b) { return cmp(b[si],a[si]); }); + + rows.forEach(function(row) { + t.push(TR(TD(row[0]), + TD(row[1]), + TD(r(row[2])), + TD(r(row[3])))); + }); + + response.write(t); +} + +function render_timingsreset() { + Packages.net.appjet.ajstdlib.timer.reset(); + response.redirect('/ep/admin/timings'); +} + +// function render_jsontest() { +// response.setContentType('text/plain; charset=utf-8'); + +// var a = []; +// a[0] = 5; +// a[1] = 6; +// a[9] = 8; +// a['foo'] = "should appear"; + +// jtest(a); + +// var obj1 = { +// a: 1, +// b: 2, +// q: [true,true,,,,,,false,false,,,,{},{a:{a:{a:{a:{a:{a:[[{a:{a:false}}]]}}}}}}], +// c: "foo", +// d: { +// nested: { obj: 'yo' }, +// bar: "baz" +// }, +// e: 3.6, +// 1: "numeric value", +// 2: "anohter numeric value", +// 2.46: "decimal numeric value", +// foo: 3.212312310, +// bar: 0.234242e-10, +// baz: null, +// ar: [{}, '1', [], [[[[]]]]], +// n1: null, +// n2: undefined, +// n3: false, +// n4: "null", +// n5: "undefined" +// }; + +// jtest(obj1); + +// var obj2 = { +// t1: 1232738532270 +// }; + +// jtest(obj2); + +// // a javascript object plus numeric ids +// var obj3 = {}; +// obj3["foo"] = "bar"; +// obj3[1] = "aaron"; +// obj3[2] = "iba"; + +// jtest(obj3); + +// function jtest(x) { +// response.write('----------------------------------------------------------------\n\n'); + +// var str1 = JSON.stringify(x); +// var str2 = fastJSON.stringify(x); + +// var str1_ = JSON.stringify(JSON.parse(str1)); +// var str2_ = fastJSON.stringify(fastJSON.parse(str2)); + +// response.write([str1,str2].join('\n') + '\n\n'); +// response.write([str1_,str2_].join('\n') + '\n\n'); +// } +// } + +function render_varz() { + var varzes = varz.getSnapshot(); + response.setContentType('text/plain; charset=utf-8'); + for (var k in varzes) { + response.write(k+': '+varzes[k]+'\n'); + } +} + +function render_extest() { + throw new Error("foo"); +} + + +function _diagnosticRecordToHtml(obj) { + function valToHtml(o, noborder) { + if (typeof (o) != 'object') { + return String(o); + } + var t = TABLE((noborder ? {} : {style: "border-left: 1px solid black; border-top: 1px solid black;"})); + if (typeof (o.length) != 'number') { + eachProperty(o, function(k, v) { + var tr = TR(); + tr.push(TD({valign: "top", align: "right"}, B(k))); + tr.push(TD(valToHtml(v))); + t.push(tr); + }); + } else { + if (o.length == 0) return "(empty array)"; + for (var i = 0; i < o.length; ++i) { + var tr = TR(); + tr.push(TD({valign: "top", align: "right"}, B(i))); + tr.push(TD(valToHtml(o[i]))); + t.push(tr); + } + } + return t; + } + return valToHtml(obj, true); +} + +function render_diagnostics() { + var start = Number(request.params.start || 0); + var count = Number(request.params.count || 100); + var diagnostic_entries = sqlbase.getAllJSON("PAD_DIAGNOSTIC", start, count); + var expandArray = request.params.expand || []; + + if (typeof (expandArray) == 'string') expandArray = [expandArray]; + var expand = {}; + for (var i = 0; i < expandArray.length; ++i) { + expand[expandArray[i]] = true; + } + + function makeLink(text, expand, collapse, start0, count0) { + start0 = (typeof(start0) == "number" ? start0 : start); + count0 = count0 || count; + collapse = collapse || []; + expand = expand || []; + + var collapseObj = {}; + for (var i = 0; i < collapse.length; ++i) { + collapseObj[collapse[i]] = true; + } + var expandString = + expandArray.concat(expand).filter(function(x) { return ! collapseObj[x] }).map(function(x) { return "expand="+encodeURIComponent(x) }).join("&"); + + var url = request.path + "?start="+start0+"&count="+count0+"&"+expandString+(expand.length == 1 ? "#"+md5(expand[0]) : ""); + + return A({href: url}, text); + } + + var t = TABLE({border: 1, cellpadding: 2, style: "font-family: monospace;"}); + diagnostic_entries.forEach(function(ent) { + var tr = TR() + tr.push(TD({valign: "top", align: "right"}, (new Date(Number(ent.id.split("-")[0]))).toString())); + tr.push(TD({valign: "top", align: "right"}, ent.id)); + if (expand[ent.id]) { + tr.push(TD(A({name: md5(ent.id)}, makeLink("(collapse)", false, [ent.id])), BR(), + _diagnosticRecordToHtml(ent.value))); + } else { + tr.push(TD(A({name: md5(ent.id)}, makeLink(_diagnosticRecordToHtml({padId: ent.value.padId, disconnectedMessage: ent.value.disconnectedMessage}), [ent.id])))); + } + t.push(tr); + }); + + var body = BODY(); + body.push(P("Showing entries ", start, "-", start+diagnostic_entries.length, ". ", + (start > 0 ? makeLink("Show previous "+count+".", [], [], start-count) : ""), + (diagnostic_entries.length == count ? makeLink("Show next "+count+".", [], [], start+count) : ""))); + body.push(t); + + response.write(HTML(body)); +} + +//---------------------------------------------------------------- +import("etherpad.billing.billing"); + +function render_testbillingdirect() { + var invoiceId = billing.createInvoice(); + var ret = billing.directPurchase(invoiceId, 0, 'EEPNET', 500, 'DISCOUNT', { + cardType: "Visa", + cardNumber: "4501251685453214", + cardExpiration: "042019", + cardCvv: "123", + nameSalutation: "Dr.", + nameFirst: "John", + nameMiddle: "D", + nameLast: "Zamfirescu", + nameSuffix: "none", + addressStreet: "531 Main St. Apt. 1227", + addressStreet2: "", + addressCity: "New York", + addressState: "NY", + addressCountry: "US", + addressZip: "10044" + }, "https://"+request.host+"/ep/about/testbillingnotify"); + if (ret.status == 'success') { + response.write(P("Success! Invoice id: "+ret.purchaseInfo.invoiceId+" for "+ret.purchaseInfo.cost)); + } else { + response.write(P("Failure: "+ret.toSource())) + } +} + +function render_testbillingrecurring() { + var invoiceId = billing.createInvoice(); + var ret = billing.directPurchase(invoiceId, 0, 'EEPNET', 1, 'DISCOUNT', { + cardType: "Visa", + cardNumber: "4501251685453214", + cardExpiration: "042019", + cardCvv: "123", + nameSalutation: "Dr.", + nameFirst: "John", + nameMiddle: "D", + nameLast: "Zamfirescu", + nameSuffix: "none", + addressStreet: "531 Main St. Apt. 1227", + addressStreet2: "", + addressCity: "New York", + addressState: "NY", + addressCountry: "US", + addressZip: "10044" + }, "https://"+request.host+"/ep/about/testbillingnotify", true); + if (ret.status == 'success') { + var transactionId = billing.getTransaction(ret.purchaseInfo.transactionId).txnId; + var purchaseId = ret.purchaseInfo.purchaseId; + response.write(P("Direct billing successful. PayPal transaction id: ", transactionId)); + + invoiceId = billing.createInvoice(); + ret = billing.asyncRecurringPurchase( + invoiceId, purchaseId, transactionId, 500, + "https://"+request.host+"/ep/about/testbillingnotify"); + if (ret.status == 'success') { + response.write(P("Woot! Recurrent billing successful! ", ret.purchaseInfo.invoiceId, " for ", ret.purchaseInfo.cost)); + } else { + response.write(P("Failure: "+ret.toSource())); + } + } else { + response.write("Direct billing failure: "+ret.toSource()); + } +} + +function render_testbillingexpress() { + var urlPrefix = "http://"+request.host+request.path; + var session = sessions.getSession(); + var notifyUrl = "http://"+request.host+"/ep/about/testbillingnotify"; + + switch (request.params.step) { + case '0': + response.write(P("You'll be charged $400 for EEPNET. Click the link below to go to paypal.")); + response.write(A({href: urlPrefix+"?step=1"}, "Link")); + break; + case '1': + var ret = billing.beginExpressPurchase(1, 'EEPNET', 400, 'DISCOUNT', urlPrefix+"?step=2", urlPrefix+"?step=0", notifyUrl); + if (ret.status != 'success') { + response.write("Error: "+ret.debug.toSource()); + response.stop(); + } + session.purchaseInfo = ret.purchaseInfo; + response.redirect(paypalPurchaseUrl(ret.purchaseInfo.token)); + break; + case '2': + var ret = billing.continueExpressPurchase(session.purchaseInfo); + if (! ret.status == 'success') { + response.write("Error: "+ret.debug.toSource()); + response.stop(); + } + session.payerInfo = ret.payerInfo; + + response.write(P("You approved the transaction. Click 'confirm' to confirm.")); + response.write(A({href: urlPrefix+"?step=3"}, "Confirm")); + break; + case '3': + var ret = billing.completeExpressPurchase(session.purchaseInfo, session.payerInfo, notifyUrl); + if (ret.status == 'failure') { + response.write("Error: "+ret.debug.toSource()); + response.stop(); + } + if (ret.status == 'pending') { + response.write("Your charge is pending. You will be notified by email when your payment clears. Your invoice number is "+session.purchaseInfo.invoiceId); + response.stop(); + } + + response.write(P("Purchase completed: invoice # is "+session.purchaseInfo.invoiceId+" for "+session.purchaseInfo.cost)); + break; + default: + response.redirect(request.path+"?step=0"); + } +} + +//---------------------------------------------------------------- + +function render_genlicense_get() { + + var t = TABLE({border: 1}); + function ti(id, label) { + t.push(TR(TD({align: "right"}, LABEL({htmlFor: id}, label+":")), + TD(INPUT({id: id, name: id, type: 'text', size: 40})))); + } + + ti("name", "Name of Licensee"); + ti("org", "Name of Organization"); + ti("userQuota", "User Quota"); + + t.push(TR(TD({align: "right"}, LABEL("Software Edtition:")), + TD( SELECT({name: "edition"}, + OPTION({value: licensing.getEditionId('PRIVATE_NETWORK_EVALUATION')}, + "Private Network EVALUATION"), + OPTION({value: licensing.getEditionId('PRIVATE_NETWORK')}, + "Private Network"))))); + + ti("expdays", "Number of days until expiration\n(leave blank if never expires)"); + + t.push(TR(TD({colspan: 2}, INPUT({type: "submit"})))); + + var f = FORM({action: request.path, method: "post"}); + f.push(t); + + response.write(HTML(BODY(f))); +} + +function render_genlicense_post() { + var name = request.params.name; + var org = request.params.org; + var editionId = +request.params.edition; + var editionName = licensing.getEditionName(editionId); + var userQuota = +request.params.userQuota; + + var expiresTime = null; + if (request.params.expdays) { + expiresTime = +(new Date) + 1000*60*60*24*(+request.params.expdays); + } + + var licenseKey = licensing.generateNewKey( + name, + org, + expiresTime, + editionId, + userQuota + ); + + // verify + if (!licensing.isValidKey(licenseKey)) { + throw Error("License key I just created is not valid: "+licenseKey); + } + + // TODO: write to database?? + // + + // display + var licenseInfo = licensing.decodeLicenseInfoFromKey(licenseKey); + var t = TABLE({border: 1}); + function line(k, v) { + t.push(TR(TD({align: "right"}, k+":"), + TD(v))); + } + + var key = licenseKey.split(":")[2]; + if ((key.length % 2) != 0) { + key = key + "+"; + } + var keyLine1 = key.substr(0, key.length/2); + var keyLine2 = key.substr(key.length/2, key.length); + + line("Name", licenseInfo.personName); + line("Organization", licenseInfo.organizationName); + line("Key", P(keyLine1, BR(), keyLine2)); + line("Software Edition", licenseInfo.editionName); + line("User Quota", licenseInfo.userQuota); + line("Expires", (+licenseInfo.expiresDate > 0) ? licenseInfo.expiresDate.toString() : "(never)"); + + response.write(HTML(BODY(t))); +} + +//---------------------------------------------------------------- + +import("etherpad.metrics.metrics"); + +function render_flows() { + if (request.params.imgId && getSession()[request.params.imgId]) { + var arr = getSession()[request.params.imgId]; + metrics[arr[0]](arr[1], Array.prototype.slice.call(arr[2])); + response.stop(); + } + + function drawHistogram(name, h) { + var imgKey = Math.round(Math.random()*1e12); + print(IMG({src: request.path+"?imgId="+imgKey})); + getSession()[imgKey] = ["respondWithPieChart", name, h]; + } + + var body = BODY(); + function print() { + for (var i = 0; i < arguments.length; ++i) { + body.push(arguments[i]); + } + } + + var [startDate, endDate] = [7, 1].map(function(a) { return new Date(Date.now() - 86400*1000*a); }); + + var allFlows = metrics.getFlows(startDate, endDate); + +/* + print(P("All flows:")); + + eachProperty(allFlows, function(k, flows) { + print(P(k, html(" » "))); + flows.forEach(function(flow) { + print(P(flow.toString())); + }); + }); + response.write(HTML(body)); + return; +*/ + + print(P("Parsing logs from: "+startDate+" through "+endDate)); + + var fs = + [metrics.getFunnel(startDate, endDate, ['/ep/about/pricing', '/ep/about/pricing-eepnet', '/ep/store/eepnet-eval-signup'], true), + metrics.getFunnel(startDate, endDate, ['/ep/about/pricing', '/ep/about/pricing-free'], true), + metrics.getFunnel(startDate, endDate, ['/ep/about/pricing', '/ep/about/pricing-eepod'], true), + metrics.getFunnel(startDate, endDate, ['/ep/about/pricing', '/ep/store/eepnet-eval-signup'], true), + metrics.getFunnel(startDate, endDate, ['/', '(pad)']), + metrics.getFunnel(startDate, endDate, ['/', '/ep/pad/newpad'], true), + metrics.getFunnel(startDate, endDate, ['/ep/about/screencast', '(pad)'])]; + + function vcnt(i, i2) { + return fs[i].visitorCounts[i2]; + } + function pct(f) { + return ""+Math.round(f*10000)/100+"%" + } + function cntAndPct(i, i2) { + if (i2 === undefined) { i2 = 1; } + return ""+vcnt(i, i2)+" ("+pct(vcnt(i, i2)/vcnt(i, i2-1))+")"; + } + print(P("Of ", vcnt(0, 0), " visitors to the pricing page, ", + cntAndPct(0), " of them viewed eepnet, (", cntAndPct(0, 2), " of those downloaded), ", + cntAndPct(1), " of them viewed free, and ", + cntAndPct(2), " of them viewed eepod. ", + cntAndPct(3), " of them clicked on the eval signup link straight up." + ), + P("Of ", vcnt(4, 0), " visitors to the home page, ", + cntAndPct(4), " of them went to a pad page in the same flow; ", + cntAndPct(5), " of them clicked the new pad button immediately."), + P("Of ", vcnt(6, 0), " vistitors to the screencast page, ", + cntAndPct(6), " of them visisted a pad page in the same flow.")); + + var origins = metrics.getOrigins(startDate, endDate, true); + print(P("Flow first origins: ")); + drawHistogram("first origins", origins.flowFirsts); + + var firstHits = metrics.getOrigins(startDate, endDate, false, true); + var padFirstHits = 0; + var nonPadFirstHits = 0; + print(P("First paths hit: ")); + drawHistogram("first paths", firstHits.flowFirsts); + firstHits.flowFirsts.filter(function(x) { + if (x.value != '/' && ! startsWith(x.value, "/ep/")) { + padFirstHits += x.count; + return false; + } + nonPadFirstHits += x.count; + return true; + }); + print(P("Some pad page: "+padFirstHits), + P("Non-pad page: "+nonPadFirstHits)); + + var exitsFromHomepage = metrics.getExits(startDate, endDate, '/', true); + print(P("Exits from homepage: ")); + drawHistogram("exits", exitsFromHomepage.histogram) + + response.write(HTML(body)); +} + +//---------------------------------------------------------------- + +import("etherpad.pad.pad_migrations"); + +function render_padmigrations() { + var residue = (request.params.r || 0); + var modulus = (request.params.m || 1); + var name = (request.params.n || (residue+"%"+modulus)); + pad_migrations.runBackgroundMigration(residue, modulus, name); + response.write("done"); + return true; +} + +// TODO: add ability to delete entries? +// TODO: show sizes? +function render_cachebrowser() { + var path = request.params.path; + if (path && path.charAt(0) == ',') { + path = path.substr(1); + } + var pathArg = (path || ""); + var c = appjet.cache; + if (path) { + path.split(",").forEach(function(part) { + c = c[part]; + }); + } + + var d = DIV({style: 'font-family: monospace; text-decoration: none;'}); + + d.push(H3("appjet.cache --> "+pathArg.split(",").join(" --> "))); + + var t = TABLE({border: 1}); + keys(c).sort().forEach(function(k) { + var v = c[k]; + if (v && (typeof(v) == 'object') && (!v.getDate)) { + t.push(TR(TD(A({style: 'text-decoration: none;', + href: request.path+"?path="+pathArg+","+k}, k)))); + } else { + t.push(TR(TD(k), TD(v))); + } + }); + + d.push(t); + response.write(d); +} + +function render_pne_tracker_get() { + var data = sqlobj.selectMulti('pne_tracking_data', {}, {}); + data.sort(function(x, y) { return cmp(y.date, x.date); }); + + var t = TABLE(); + + var headrow = TR(); + ['date', 'remote host', 'keyHash', 'name', 'value'].forEach(function(x) { + headrow.push(TH({align: "left", style: "padding: 0 6px;"}, x)); + }); + t.push(headrow); + + data.forEach(function(d) { + var tr = TR(); + + tr.push(TD(d.date.toString().split(' ').slice(0,5).join('-'))); + + if (d.remoteIp) { + tr.push(TD(netutils.getHostnameFromIp(d.remoteIp) || d.remoteIp)); + } else { + tr.push(TD("-")); + } + + if (d.keyHash) { + tr.push(TD(A({href: '/ep/admin/pne-tracker-lookup-keyhash?hash='+d.keyHash}, d.keyHash))); + } else { + tr.push(TD("-")); + } + + tr.push(TD(d.name)); + tr.push(TD(d.value)); + + t.push(tr); + }); + + response.write(HTML(HEAD(html("<style>td { border-bottom: 1px solid #ddd; border-right: 1px solid #ddd; padding: 0 6px; } \n tr:hover { background: #ffc; }</style>"), + BODY({style: "font-family: monospace; font-size: 12px;"}, t)))); +} + +function render_pne_tracker_lookup_keyhash_get() { + var hash = request.params.hash; + // brute force it + var allLicenses = sqlobj.selectMulti('eepnet_signups', {}, {}); + var record = null; + var i = 0; + while (i < allLicenses.length && record == null) { + var d = allLicenses[i]; + if (md5(d.licenseKey).substr(0, 16) == hash) { + record = d; + } + i++; + } + if (!record) { + response.write("Not found. Perhaps this was a test download from local development, or a paid customer whose licenses we don't currently look through on this page."); + } else { + var kl = keys(record).sort(); + var t = TABLE(); + kl.forEach(function(k) { + t.push(TR(TH({align: "right"}, k+":"), + TD({style: "padding-left: 1em;"}, record[k]))); + }); + response.write(HTML(BODY(DIV({style: "font-family: monospace;"}, + DIV(H1("Trial Signup Record:")), t)))); + } +} + +function render_reload_blog_db_get() { + var d = DIV(); + if (request.params.ok) { + d.push(DIV(P("OK"))); + } + d.push(FORM({method: "post", action: request.path}, + INPUT({type: "submit", value: "Reload Blog DB Now"}))); + response.write(HTML(BODY(d))); +} + +function render_reload_blog_db_post() { + blogcontrol.reloadBlogDb(); + response.redirect(request.path+"?ok=1"); +} + +function render_pro_domain_accounts() { + var accounts = sqlobj.selectMulti('pro_accounts', {}, {}); + var domains = sqlobj.selectMulti('pro_domains', {}, {}); + + // build domain map + var domainMap = {}; + domains.forEach(function(d) { domainMap[d.id] = d; }); + accounts.sort(function(a,b) { return cmp(b.lastLoginDate, a.lastLoginDate); }); + + var b = BODY({style: "font-family: monospace;"}); + b.push(accounts.length + " pro accounts."); + var t = TABLE({border: 1}); + t.push(TR(TH("email"), + TH("domain"), + TH("lastLogin"))); + accounts.forEach(function(u) { + t.push(TR(TD(u.email), + TD(domainMap[u.domainId].subDomain+"."+request.domain), + TD(u.lastLoginDate))); + }); + + b.push(t); + + response.write(HTML(b)); +} + + +function render_beta_valve_get() { + var d = DIV( + P("Beta Valve Status: ", + (pro_beta_control.isValveOpen() ? + SPAN({style: "color: green;"}, B("OPEN")) : + SPAN({style: "color: red;"}, B("CLOSED")))), + P(FORM({action: '/ep/admin/beta-valve-toggle', method: "post"}, + BUTTON({type: "submit"}, "Toggle")))); + + var t = TABLE({border: 1, cellspacing: 0, cellpadding: 4, style: "font-family: monospace;"}); + var signupList = sqlobj.selectMulti('pro_beta_signups', {}, {}); + signupList.sort(function(a, b) { + return cmp(b.signupDate, a.signupDate); + }); + + d.push(HR()); + + if (getSession().betaAdminMessage) { + d.push(DIV({style: "border: 1px solid #ccc; padding: 1em; background: #eee;"}, + getSession().betaAdminMessage)); + delete getSession().betaAdminMessage; + } + + d.push(P(signupList.length + " beta signups")); + + d.push(FORM({action: '/ep/admin/beta-invite-multisend', method: 'post'}, + P("Send ", INPUT({type: 'text', name: 'count', size: 3}), " invites."), + INPUT({type: "submit"}))); + + t.push(TR(TH("id"), TH("email"), TH("signupDate"), + TH("activationDate"), TH("activationCode"), TH(' '))); + + signupList.forEach(function(s) { + var tr = TR(); + tr.push(TD(s.id), + TD(s.email), + TD(s.signupDate), + TD(s.isActivated ? s.activationDate : "-"), + TD(s.activationCode)); + if (!s.activationCode) { + tr.push(TD(FORM({action: '/ep/admin/beta-invite-send', method: 'post'}, + INPUT({type: 'hidden', name: 'id', value: s.id}), + INPUT({type: 'submit', value: "Send Invite"})))); + } else { + tr.push(TD(' ')); + } + t.push(tr); + }); + d.push(t); + response.write(d); +} + +function render_beta_valve_toggle_post() { + pro_beta_control.toggleValve(); + response.redirect('/ep/admin/beta-valve'); +} + +function render_beta_invite_send_post() { + var id = request.params.id; + pro_beta_control.sendInvite(id); + response.redirect('/ep/admin/beta-valve'); +} + +function render_beta_invite_multisend_post() { + var count = request.params.count; + var signupList = sqlobj.selectMulti('pro_beta_signups', {}, {}); + signupList.sort(function(a, b) { + return cmp(a.signupDate, b.signupDate); + }); + var sent = 0; + for (var i = 0; ((i < signupList.length) && (sent < count)); i++) { + var record = signupList[i]; + if (!record.activationCode) { + pro_beta_control.sendInvite(record.id); + sent++; + } + } + getSession().betaAdminMessage = (sent+" invites sent."); + response.redirect('/ep/admin/beta-valve'); +} + +function render_usagestats() { + response.redirect("/ep/admin/usagestats/"); +} + +function render_exceptions() { + exceptions.render(); +} + +function render_setadminmode() { + sessions.setIsAnEtherpadAdmin( + String(request.params.v).toLowerCase() == "true"); + response.redirect("/ep/admin/"); +} + +// -------------------------------------------------------------- +// billing-related +// -------------------------------------------------------------- + +// some of these functions are only used from selenium tests, and so have no UI. + +function render_setdomainpaidthrough() { + var domainName = request.params.domain; + var when = new Date(Number(request.params.paidthrough)); + if (! domainName || ! when) { + response.write("fail"); + response.stop(); + } + var domain = domains.getDomainRecordFromSubdomain(domainName); + var domainId = domain.id; + + var subscription = team_billing.getSubscriptionForCustomer(domainId); + if (subscription) { + billing.updatePurchase(subscription.id, {paidThrough: when}); + team_billing.domainCacheClear(domainId); + response.write("OK"); + } else { + response.write("fail"); + } +} + +function render_runsubscriptions() { + team_billing.processAllSubscriptions(); + response.write("OK"); +} + +function render_reset_subscription() { + var body = BODY(); + if (request.isGet) { + body.push(FORM({method: "POST"}, + "Subdomain: ", INPUT({type: "text", name: "subdomain"}), BUTTON({name: "clear"}, "Go"))); + } else if (request.isPost) { + if (! request.params.confirm) { + var domain = domains.getDomainRecordFromSubdomain(request.params.subdomain); + var admins = pro_accounts.listAllDomainAdmins(domain.id); + body.push(P("Domain ", domain.subDomain, ".", request.domain, "; admins:")); + var p = UL(); + admins.forEach(function(admin) { + p.push(LI(admin.fullName, " <", admin.email, ">")); + }); + body.push(p); + var subscription = team_billing.getSubscriptionForCustomer(domain.id); + if (subscription) { + body.push(P("Subscription is currently ", subscription.status, ", and paid through: ", checkout.formatDate(subscription.paidThrough), ".")) + body.push(FORM({method: "POST"}, + INPUT({type: "hidden", name: "subdomain", value: request.params.subdomain}), + "Are you sure? ", BUTTON({name: "confirm", value: "yes"}, "YES"))); + } else { + body.push(P("No current subscription")); + } + } else { + var domain = domains.getDomainRecordFromSubdomain(request.params.subdomain); + sqlcommon.inTransaction(function() { + team_billing.resetMaxUsers(domain.id); + sqlobj.deleteRows('billing_purchase', {customer: domain.id, type: 'subscription'}); + team_billing.domainCacheClear(domain.id); + team_billing.clearRecurringBillingInfo(domain.id); + }); + body.push("Done!") + } + } + body.push(A({href: request.path}, html("« back"))); + response.write(HTML(body)); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/control/blogcontrol.js b/trunk/etherpad/src/etherpad/control/blogcontrol.js new file mode 100644 index 0000000..9ec485d --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/blogcontrol.js @@ -0,0 +1,199 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//blogcontrol + +import("jsutils.*"); +import("atomfeed"); +import("funhtml.*"); + +import("etherpad.globals.*"); +import("etherpad.utils.*"); +import("etherpad.quotas"); + +//---------------------------------------------------------------- +// bloghelpers +//---------------------------------------------------------------- +bloghelpers = {}; +bloghelpers.disqusDeveloper = function() { + if (isProduction()) { + return ''; + } + return [ + '<script type="text/javascript">', + ' var disqus_developer = 1;', + '</script>' + ].join('\n'); +}; + +bloghelpers.feedburnerUrl = function() { + var name = isProduction() ? "TheEtherPadBlog" : "TheEtherPadBlogDev"; + return "http://feeds.feedburner.com/"+name; +}; + +bloghelpers.feedLink = function() { + return [ + '<link rel="alternate"', + ' title="EtherPad Blog Feed"', + ' href="', bloghelpers.feedburnerUrl(), '"', + ' type="application/rss+xml" />' + ].join(''); +}; + +bloghelpers.dfmt = function(d) { + return d.toString().split(' ').slice(0,3).join(' '); +}; + +bloghelpers.feedbuttonHtml = function() { + var aProps = { + href: bloghelpers.feedburnerUrl(), + rel: "alternate", + type: "application/rss+xml" + }; + + return SPAN(A(aProps, + IMG({src: "http://www.feedburner.com/fb/images/pub/feed-icon32x32.png", + alt: "EtherPad Blog Feed", + style: "vertical-align:middle; border:0;"}))).toHTML(); +}; + +bloghelpers.getMaxUsersPerPad = function() { + return quotas.getMaxSimultaneousPadEditors() +}; + +//---------------------------------------------------------------- +// posts "database" +//---------------------------------------------------------------- + +function _wrapPost(p) { + var wp = {}; + keys(p).forEach(function(k) { wp[k] = p[k]; }); + wp.url = function() { + return "http://"+request.host+"/ep/blog/posts/"+p.id; + }; + wp.renderContent = function() { + return renderTemplateAsString("blog/posts/"+p.id+".ejs", + {post: wp, bloghelpers: bloghelpers}); + }; + return wp; +} + +function _addPost(id, title, author, published, updated) { + if (!appjet.cache.blogDB) { + appjet.cache.blogDB = { + posts: [], + postMap: {} + }; + } + var p = {id: id, title: title, author: author, published: published, updated: updated}; + appjet.cache.blogDB.posts.push(p); + appjet.cache.blogDB.postMap[p.id] = p; +} + +function _getPostById(id) { + var p = appjet.cache.blogDB.postMap[id]; + if (!p) { return null; } + return _wrapPost(p); +} + +function _getAllPosts() { + return []; +} + +function _sortBlogDB() { + appjet.cache.blogDB.posts.sort(function(a,b) { return cmp(b.published, a.published); }); +} + +//---------------------------------------------------------------- +// Posts +//---------------------------------------------------------------- + +function _initBlogDB() { + return; +} + +function reloadBlogDb() { + delete appjet.cache.blogDB; + _initBlogDB(); +} + +function onStartup() { + reloadBlogDb(); +} + +//---------------------------------------------------------------- +// onRequest +//---------------------------------------------------------------- +function onRequest(name) { + // nothing yet. +} + +//---------------------------------------------------------------- +// main +//---------------------------------------------------------------- +function render_main() { + renderFramed('blog/blog_main_body.ejs', + {posts: _getAllPosts(), bloghelpers: bloghelpers}); +} + +//---------------------------------------------------------------- +// render_feed +//---------------------------------------------------------------- +function render_feed() { + var lastModified = new Date(); // TODO: most recent of all entries modified + + var entries = []; + _getAllPosts().forEach(function(post) { + entries.push({ + title: post.title, + author: post.author, + published: post.published, + updated: post.updated, + href: post.url(), + content: post.renderContent() + }); + }); + + response.setContentType("application/atom+xml; charset=utf-8"); + + response.write(atomfeed.renderFeed( + "The EtherPad Blog", new Date(), entries, + "http://"+request.host+"/ep/blog/")); +} + +//---------------------------------------------------------------- +// render_post +//---------------------------------------------------------------- +function render_post(name) { + var p = _getPostById(name); + if (!p) { + return false; + } + renderFramed('blog/blog_post_body.ejs', { + post: p, bloghelpers: bloghelpers, + posts: _getAllPosts() + }); + return true; +} + +//---------------------------------------------------------------- +// render_new_from_etherpad() +//---------------------------------------------------------------- + +function render_new_from_etherpad() { + return ""; +} + diff --git a/trunk/etherpad/src/etherpad/control/connection_diagnostics_control.js b/trunk/etherpad/src/etherpad/control/connection_diagnostics_control.js new file mode 100644 index 0000000..aaa1bb3 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/connection_diagnostics_control.js @@ -0,0 +1,87 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.utils.*"); +import("etherpad.helpers.*"); + +//---------------------------------------------------------------- +// Connection diagnostics +//---------------------------------------------------------------- + +/* +function _getDiagnosticsCollection() { + var db = storage.getRoot("connection_diagnostics"); + if (!db.diagnostics) { + db.diagnostics = new StorableCollection(); + } + return db.diagnostics; +} +*/ + +function render_main_get() { + /* + var diagnostics = _getDiagnosticsCollection(); + + var data = new StorableObject({ + ip: request.clientAddr, + userAgent: request.headers['User-Agent'] + }); + + diagnostics.add(data); + + helpers.addClientVars({ + diagnosticStorableId: data.id + }); +*/ + renderFramed("main/connection_diagnostics_body.ejs"); +} + +function render_submitdata_post() { + response.setContentType('text/plain; charset=utf-8'); + /* + var id = request.params.diagnosticStorableId; + var storedData = storage.getStorable(id); + if (!storedData) { + response.write("Error retreiving diagnostics record."); + response.stop(); + } + var diagnosticData = JSON.parse(request.params.dataJson); + eachProperty(diagnosticData, function(k,v) { + storedData[k] = v; + }); +*/ + response.write("OK"); +} + +function render_submitemail_post() { + response.setContentType('text/plain; charset=utf-8'); + /* + var id = request.params.diagnosticStorableId; + var data = storage.getStorable(id); + if (!data) { + response.write("Error retreiving diagnostics record."); + response.stop(); + } + var email = request.params.email; + if (!isValidEmail(email)) { + response.write("Invalid email address."); + response.stop(); + } + data.email = email; +*/ + response.write("OK"); +} + diff --git a/trunk/etherpad/src/etherpad/control/global_pro_account_control.js b/trunk/etherpad/src/etherpad/control/global_pro_account_control.js new file mode 100644 index 0000000..65d2124 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/global_pro_account_control.js @@ -0,0 +1,143 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("funhtml.*"); +import("stringutils"); +import("stringutils.*"); +import("email.sendEmail"); +import("cache_utils.syncedWithCache"); + +import("etherpad.utils.*"); +import("etherpad.sessions.getSession"); + +import("etherpad.pro.domains"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_utils"); + +jimport("java.lang.System.out.println"); + +function onRequest() { + if (!getSession().oldFormData) { + getSession().oldFormData = {}; + } + return false; // not handled yet. +} + +function _errorDiv() { + var m = getSession().proAccountControlError; + delete getSession().proAccountControlError; + if (m) { + return DIV({className: "error"}, m); + } + return ""; +} + +function _redirectError(m) { + getSession().proAccountControlError = m; + response.redirect(request.path); +} + + +function render_main_get() { + response.redirect('/ep/pro-account/sign-in'); +} + +function render_sign_in_get() { + renderFramed('pro-account/sign-in.ejs', { + oldData: getSession().oldFormData, + errorDiv: _errorDiv + }); +} + + +function render_sign_in_post() { + var email = trim(request.params.email); + var password = request.params.password; + var subDomain = request.params.subDomain; + + subDomain = subDomain.toLowerCase(); + + getSession().oldFormData.email = email; + getSession().oldFormData.subDomain = subDomain; + + var domainRecord = domains.getDomainRecordFromSubdomain(subDomain); + if (!domainRecord) { + _redirectError("Site address not found: "+subDomain+"."+request.host); + } + + var instantSigninKey = stringutils.randomString(20); + syncedWithCache('global_signin_passwords', function(c) { + c[instantSigninKey] = { + email: email, + password: password + }; + }); + + response.redirect( + "https://"+subDomain+"."+httpsHost(request.host)+ + "/ep/account/sign-in?instantSigninKey="+instantSigninKey); +} + +function render_recover_get() { + renderFramed('pro-account/recover.ejs', { + oldData: getSession().oldFormData, + errorDiv: _errorDiv + }); +} + +function render_recover_post() { + + function _recoverLink(accountRecord, domainRecord) { + var host = (domainRecord.subDomain + "." + httpsHost(request.host)); + return ( + "https://"+host+"/ep/account/forgot-password?instantSubmit=1&email="+ + encodeURIComponent(accountRecord.email)); + } + + var email = trim(request.params.email); + + // lookup all domains associated with this email + var accountList = pro_accounts.getAllAccountsWithEmail(email); + println("account records matching ["+email+"]: "+accountList.length); + + var domainList = []; + for (var i = 0; i < accountList.length; i++) { + domainList[i] = domains.getDomainRecord(accountList[i].domainId); + } + + if (accountList.length == 0) { + _redirectError("No accounts were found associated with the email address \""+email+"\"."); + } + if (accountList.length == 1) { + response.redirect(_recoverLink(accountList[0], domainList[0])); + } + if (accountList.length > 1) { + var fromAddr = '"EtherPad" <noreply@pad.spline.inf.fu-berlin.de>'; + var subj = "EtherPad: account information"; + var body = renderTemplateAsString( + 'pro/account/global-multi-domain-recover-email.ejs', { + accountList: accountList, + domainList: domainList, + recoverLink: _recoverLink, + email: email + } + ); + sendEmail(email, fromAddr, subj, {}, body); + pro_utils.renderFramedMessage("Instructions have been sent to "+email+"."); + } +} + + diff --git a/trunk/etherpad/src/etherpad/control/historycontrol.js b/trunk/etherpad/src/etherpad/control/historycontrol.js new file mode 100644 index 0000000..a78cfad --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/historycontrol.js @@ -0,0 +1,226 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("fastJSON"); +import("etherpad.utils.render404"); +import("etherpad.pad.model"); +import("etherpad.collab.collab_server"); +import("etherpad.collab.ace.easysync2.*"); +import("jsutils.eachProperty"); + +function _urlCache() { + if (!appjet.cache.historyUrlCache) { + appjet.cache.historyUrlCache = {}; + } + return appjet.cache.historyUrlCache; +} + +function _replyWithJSONAndCache(obj) { + obj.apiversion = _VERSION; + var output = fastJSON.stringify(obj); + _urlCache()[request.path] = output; + response.write(output); + response.stop(); +} + +function _replyWithJSON(obj) { + obj.apiversion = _VERSION; + response.write(fastJSON.stringify(obj)); + response.stop(); +} + +function _error(msg, num) { + _replyWithJSON({error: String(msg), errornum: num}); +} + +var _VERSION = 1; + +var _ERROR_REVISION_NUMBER_TOO_LARGE = 14; + +function _do_text(padId, r) { + if (! padId) render404(); + model.accessPadGlobal(padId, function(pad) { + if (! pad.exists()) { + render404(); + } + if (r > pad.getHeadRevisionNumber()) { + _error("Revision number too large", _ERROR_REVISION_NUMBER_TOO_LARGE); + } + var text = pad.getInternalRevisionText(r); + text = _censorText(text); + _replyWithJSONAndCache({ text: text }); + }); +} + +function _do_stat(padId) { + var obj = {}; + if (! padId) { + obj.exists = false; + } + else { + model.accessPadGlobal(padId, function(pad) { + if (! pad.exists()) { + obj.exists = false; + } + else { + obj.exists = true; + obj.latestRev = pad.getHeadRevisionNumber(); + } + }); + } + _replyWithJSON(obj); +} + +function _censorText(text) { + // may not change length of text + return text.replace(/(http:\/\/pad.spline.inf.fu-berlin.de\/)(\w+)/g, function(url, u1, u2) { + return u1 + u2.replace(/\w/g, '-'); + }); +} + +function _do_changes(padId, first, last) { + if (! padId) render404(); + + var charPool = []; + var changeList = []; + + function charPoolText(txt) { + charPool.push(txt); + return _encodeVarInt(txt.length); + } + + model.accessPadGlobal(padId, function(pad) { + + if (first > pad.getHeadRevisionNumber() || last > pad.getHeadRevisionNumber()) { + _error("Revision number too large", _ERROR_REVISION_NUMBER_TOO_LARGE); + } + + var curAText = Changeset.makeAText("\n"); + if (first > 0) { + curAText = pad.getInternalRevisionAText(first - 1); + } + curAText.text = _censorText(curAText.text); + var lastTimestamp = null; + for(var r=first;r<=last;r++) { + var binRev = []; + var timestamp = +pad.getRevisionDate(r); + binRev.push(_encodeTimeStamp(timestamp, lastTimestamp)); + lastTimestamp = timestamp; + binRev.push(_encodeVarInt(1)); // fake author + + var c = pad.getRevisionChangeset(r); + var splices = Changeset.toSplices(c); + splices.forEach(function (splice) { + var startChar = splice[0]; + var endChar = splice[1]; + var newText = splice[2]; + oldText = curAText.text.substring(startChar, endChar); + + if (oldText.length == 0) { + binRev.push('+'); + binRev.push(_encodeVarInt(startChar)); + binRev.push(charPoolText(newText)); + } + else if (newText.length == 0) { + binRev.push('-'); + binRev.push(_encodeVarInt(startChar)); + binRev.push(charPoolText(oldText)); + } + else { + binRev.push('*'); + binRev.push(_encodeVarInt(startChar)); + binRev.push(charPoolText(oldText)); + binRev.push(charPoolText(newText)); + } + }); + changeList.push(binRev.join('')); + + curAText = Changeset.applyToAText(c, curAText, pad.pool()); + } + + _replyWithJSONAndCache({charPool: charPool.join(''), changes: changeList.join(',')}); + + }); +} + +function render_history(padOpaqueRef, rest) { + if (_urlCache()[request.path]) { + response.write(_urlCache()[request.path]); + response.stop(); + return true; + } + var padId; + if (padOpaqueRef == "CSi1xgbFXl" || padOpaqueRef == "13sentences") { + // made-up, hard-coded opaque ref, should be a table for these + padId = "jbg5HwzUX8"; + } + else if (padOpaqueRef == "dO1j7Zf34z" || padOpaqueRef == "foundervisa") { + // made-up, hard-coded opaque ref, should be a table for these + padId = "3hS7kQyDXG"; + } + else { + padId = null; + } + var regexResult; + if ((regexResult = /^stat$/.exec(rest))) { + _do_stat(padId); + } + else if ((regexResult = /^text\/(\d+)$/.exec(rest))) { + var r = Number(regexResult[1]); + _do_text(padId, r); + } + else if ((regexResult = /^changes\/(\d+)-(\d+)$/.exec(rest))) { + _do_changes(padId, Number(regexResult[1]), Number(regexResult[2])); + } + else { + return false; + } +} + +function _encodeVarInt(num) { + var n = +num; + if (isNaN(n)) { + throw new Error("Can't encode non-number "+num); + } + var chars = []; + var done = false; + while (! done) { + if (n < 32) done = true; + var nd = (n % 32); + if (chars.length > 0) { + // non-first, will become non-last digit + nd = (nd | 32); + } + chars.push(_BASE64_DIGITS[nd]); + n = Math.floor(n / 32) + } + return chars.reverse().join(''); +} +var _BASE64_DIGITS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._"; + +function _encodeTimeStamp(tMillis, baseMillis) { + var t = Math.floor(tMillis/1000); + var base = Math.floor(baseMillis/1000); + var absolute = ["+", t]; + var resultPair = absolute; + if (((typeof base) == "number") && base <= t) { + var relative = ["", t - base]; + if (relative[1] < absolute[1]) { + resultPair = relative; + } + } + return resultPair[0] + _encodeVarInt(resultPair[1]); +} diff --git a/trunk/etherpad/src/etherpad/control/loadtestcontrol.js b/trunk/etherpad/src/etherpad/control/loadtestcontrol.js new file mode 100644 index 0000000..2a4e3f7 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/loadtestcontrol.js @@ -0,0 +1,93 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.pad.model"); +import("etherpad.pad.padutils"); +import("etherpad.pad.dbwriter"); +import("etherpad.pad.activepads"); +import("etherpad.control.pad.pad_control"); +import("etherpad.collab.collab_server"); + +// NOTE: we need to talk before enabling this again, for potential security vulnerabilities. +var LOADTEST_ENABLED = false; + +function onRequest() { + if (!LOADTEST_ENABLED) { + response.forbid(); + } +} + +function render_createpad() { + var padId = request.params.padId; + + padutils.accessPadLocal(padId, function(pad) { + if (! pad.exists()) { + pad.create(pad_control.getDefaultPadText()); + } + }); + + activepads.touch(padId); + response.write("OK"); +} + +function render_readpad() { + var padId = request.params.padId; + + padutils.accessPadLocal(padId, function(pad) { + /* nothing */ + }); + + activepads.touch(padId); + response.write("OK"); +} + +function render_appendtopad() { + var padId = request.params.padId; + var text = request.params.text; + + padutils.accessPadLocal(padId, function(pad) { + collab_server.appendPadText(pad, text); + }); + + activepads.touch(padId); + response.write("OK"); +} + +function render_flushpad() { + var padId = request.params.padId; + + padutils.accessPadLocal(padId, function(pad) { + dbwriter.writePadNow(pad, true); + }); + + activepads.touch(padId); + response.write("OK"); +} + +function render_setpadtext() { + var padId = request.params.padId; + var text = request.params.text; + + padutils.accessPadLocal(padId, function(pad) { + collab_server.setPadText(pad, text); + }); + + activepads.touch(padId); + response.write("OK"); +} + + + diff --git a/trunk/etherpad/src/etherpad/control/maincontrol.js b/trunk/etherpad/src/etherpad/control/maincontrol.js new file mode 100644 index 0000000..261ddaf --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/maincontrol.js @@ -0,0 +1,54 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("fastJSON"); +import("funhtml.*"); +import("stringutils.toHTML"); + +import("etherpad.globals.*"); +import("etherpad.helpers.*"); +import("etherpad.licensing"); +import("etherpad.log"); +import("etherpad.utils.*"); + +import("etherpad.control.blogcontrol"); + +import("etherpad.pad.model"); +import("etherpad.collab.collab_server"); + +jimport("java.lang.System.out.println"); + +//---------------------------------------------------------------- + +function render_main() { + if (request.path == '/ep/') { + response.redirect('/'); + } + renderFramed('main/home.ejs', { + newFromEtherpad: blogcontrol.render_new_from_etherpad() + }); + return true; +} + +function render_support() { + renderFramed("main/support_body.ejs"); +} + +function render_changelog_get() { + renderFramed("main/changelog.ejs"); +} + + diff --git a/trunk/etherpad/src/etherpad/control/pad/pad_changeset_control.js b/trunk/etherpad/src/etherpad/control/pad/pad_changeset_control.js new file mode 100644 index 0000000..5af7ed0 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pad/pad_changeset_control.js @@ -0,0 +1,280 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.helpers"); +import("etherpad.pad.model"); +import("etherpad.pad.padutils"); +import("etherpad.utils.*"); +import("fastJSON"); +import("etherpad.collab.server_utils.*"); +import("etherpad.collab.ace.easysync2.{AttribPool,Changeset}"); +import("cache_utils.syncedWithCache"); +import("etherpad.log"); +jimport("net.appjet.common.util.LimitedSizeMapping"); + +import("stringutils"); +import("stringutils.sprintf"); + +var _JSON_CACHE_SIZE = 10000; + +// to clear: appjet.cache.pad_changeset_control.jsoncache.map.clear() +function _getJSONCache() { + return syncedWithCache('pad_changeset_control.jsoncache', function(cache) { + if (! cache.map) { + cache.map = new LimitedSizeMapping(_JSON_CACHE_SIZE); + } + return cache.map; + }); +} + +var _profiler = { + t: 0, + laps: [], + active: false, + start: function() { + _profiler.t = +new Date; + _profiler.laps = []; + //_profiler.active = true; + }, + lap: function(name) { + if (! _profiler.active) return; + var t2 = +new Date; + _profiler.laps.push([name, t2 - _profiler.t]); + }, + dump: function(info) { + if (! _profiler.active) return; + function padright(s, len) { + s = String(s); + return s + new Array(Math.max(0,len-s.length+1)).join(' '); + } + var str = padright(info,20)+": "; + _profiler.laps.forEach(function(e) { + str += padright(e.join(':'), 8); + }); + java.lang.System.out.println(str); + }, + stop: function() { + _profiler.active = false; + } +}; + +function onRequest() { + _profiler.start(); + + var parts = request.path.split('/'); + // TODO(kroo): create a mapping between padId and read-only id + var urlId = parts[4]; + var padId = parseUrlId(urlId).localPadId; + // var revisionId = parts[5]; + + padutils.accessPadLocal(padId, function(pad) { + if (! pad.exists() && pad.getSupportsTimeSlider()) { + response.forbid(); + } + }, 'r'); + + // use the query string to specify start and end revision numbers + var startRev = parseInt(request.params["s"]); + var endRev = startRev + 100 * parseInt(request.params["g"]); + var granularity = parseInt(request.params["g"]); + + _profiler.lap('A'); + var changesetsJson = + getCacheableChangesetInfoJSON(padId, startRev, endRev, granularity); + _profiler.lap('X'); + + //TODO(kroo): set content-type to javascript + response.write(changesetsJson); + _profiler.lap('J'); + if (request.acceptsGzip) { + response.setGzip(true); + } + + _profiler.lap('Z'); + _profiler.dump(startRev+'/'+granularity+'/'+endRev); + _profiler.stop(); + + return true; +} + +function getCacheableChangesetInfoJSON(padId, startNum, endNum, granularity) { + padutils.accessPadLocal(padId, function(pad) { + var lastRev = pad.getHeadRevisionNumber(); + if (endNum > lastRev+1) { + endNum = lastRev+1; + } + endNum = Math.floor(endNum / granularity)*granularity; + }, 'r'); + + var cacheKey = "C/"+startNum+"/"+endNum+"/"+granularity+"/"+ + padutils.getGlobalPadId(padId); + + var cache = _getJSONCache(); + + var cachedJson = cache.get(cacheKey); + if (cachedJson) { + cache.touch(cacheKey); + //java.lang.System.out.println("HIT! "+cacheKey); + return cachedJson; + } + else { + var result = getChangesetInfo(padId, startNum, endNum, granularity); + var json = fastJSON.stringify(result); + cache.put(cacheKey, json); + //java.lang.System.out.println("MISS! "+cacheKey); + return json; + } +} + +// uses changesets whose numbers are between startRev (inclusive) +// and endRev (exclusive); 0 <= startNum < endNum +function getChangesetInfo(padId, startNum, endNum, granularity) { + var forwardsChangesets = []; + var backwardsChangesets = []; + var timeDeltas = []; + var apool = new AttribPool(); + + var callId = stringutils.randomString(10); + + log.custom("getchangesetinfo", {event: "start", callId:callId, + padId:padId, startNum:startNum, + endNum:endNum, granularity:granularity}); + + // This function may take a while and avoids holding a lock on the pad. + // Though the pad may change during execution of this function, + // after we retrieve the HEAD revision number, all other accesses + // are unaffected by new revisions being added to the pad. + + var lastRev; + padutils.accessPadLocal(padId, function(pad) { + lastRev = pad.getHeadRevisionNumber(); + }, 'r'); + + if (endNum > lastRev+1) { + endNum = lastRev+1; + } + endNum = Math.floor(endNum / granularity)*granularity; + + var lines; + padutils.accessPadLocal(padId, function(pad) { + lines = _getPadLines(pad, startNum-1); + }, 'r'); + _profiler.lap('L'); + + var compositeStart = startNum; + while (compositeStart < endNum) { + var whileBodyResult = padutils.accessPadLocal(padId, function(pad) { + _profiler.lap('c0'); + if (compositeStart + granularity > endNum) { + return "break"; + } + var compositeEnd = compositeStart + granularity; + var forwards = _composePadChangesets(pad, compositeStart, compositeEnd); + _profiler.lap('c1'); + var backwards = Changeset.inverse(forwards, lines.textlines, + lines.alines, pad.pool()); + + _profiler.lap('c2'); + Changeset.mutateAttributionLines(forwards, lines.alines, pad.pool()); + _profiler.lap('c3'); + Changeset.mutateTextLines(forwards, lines.textlines); + _profiler.lap('c4'); + + var forwards2 = Changeset.moveOpsToNewPool(forwards, pad.pool(), apool); + _profiler.lap('c5'); + var backwards2 = Changeset.moveOpsToNewPool(backwards, pad.pool(), apool); + _profiler.lap('c6'); + function revTime(r) { + var date = pad.getRevisionDate(r); + var s = Math.floor((+date)/1000); + //java.lang.System.out.println("time "+r+": "+s); + return s; + } + + var t1, t2; + if (compositeStart == 0) { + t1 = revTime(0); + } + else { + t1 = revTime(compositeStart - 1); + } + t2 = revTime(compositeEnd - 1); + timeDeltas.push(t2 - t1); + + _profiler.lap('c7'); + forwardsChangesets.push(forwards2); + backwardsChangesets.push(backwards2); + + compositeStart += granularity; + }, 'r'); + if (whileBodyResult == "break") { + break; + } + } + + log.custom("getchangesetinfo", {event: "finish", callId:callId, + padId:padId, startNum:startNum, + endNum:endNum, granularity:granularity}); + + return { forwardsChangesets:forwardsChangesets, + backwardsChangesets:backwardsChangesets, + apool: apool.toJsonable(), + actualEndNum: endNum, + timeDeltas: timeDeltas }; +} + +// Compose a series of consecutive changesets from a pad. +// precond: startNum < endNum +function _composePadChangesets(pad, startNum, endNum) { + if (endNum - startNum > 1) { + var csFromPad = pad.getCoarseChangeset(startNum, endNum - startNum); + if (csFromPad) { + //java.lang.System.out.println("HIT! "+startNum+"-"+endNum); + return csFromPad; + } + else { + //java.lang.System.out.println("MISS! "+startNum+"-"+endNum); + } + //java.lang.System.out.println("composePadChangesets: "+startNum+','+endNum); + } + var changeset = pad.getRevisionChangeset(startNum); + for(var r=startNum+1; r<endNum; r++) { + var cs = pad.getRevisionChangeset(r); + changeset = Changeset.compose(changeset, cs, pad.pool()); + } + return changeset; +} + +// Get arrays of text lines and attribute lines for a revision +// of a pad. +function _getPadLines(pad, revNum) { + var atext; + _profiler.lap('PL0'); + if (revNum >= 0) { + atext = pad.getInternalRevisionAText(revNum); + } + else { + atext = Changeset.makeAText("\n"); + } + _profiler.lap('PL1'); + var result = {}; + result.textlines = Changeset.splitTextLines(atext.text); + _profiler.lap('PL2'); + result.alines = Changeset.splitAttributionLines(atext.attribs, + atext.text); + _profiler.lap('PL3'); + return result; +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/control/pad/pad_control.js b/trunk/etherpad/src/etherpad/control/pad/pad_control.js new file mode 100644 index 0000000..3c32202 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pad/pad_control.js @@ -0,0 +1,780 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("funhtml.*"); +import("comet"); +import("email.sendEmail"); +import("fastJSON"); +import("jsutils.eachProperty"); +import("sqlbase.sqlbase"); +import("stringutils.{toHTML,md5}"); +import("stringutils"); + +import("etherpad.collab.collab_server"); +import("etherpad.debug.dmesg"); +import("etherpad.globals.*"); +import("etherpad.helpers"); +import("etherpad.licensing"); +import("etherpad.quotas"); +import("etherpad.log"); +import("etherpad.log.{logRequest,logException}"); +import("etherpad.sessions"); +import("etherpad.sessions.getSession"); +import("etherpad.utils.*"); +import("etherpad.pro.pro_padmeta"); +import("etherpad.pro.pro_pad_db"); +import("etherpad.pro.pro_utils"); +import("etherpad.pro.pro_accounts.getSessionProAccount"); +import("etherpad.pro.domains"); +import("etherpad.pro.pro_config"); +import("etherpad.pne.pne_utils"); +import("etherpad.pro.pro_quotas"); + +import("etherpad.pad.revisions"); +import("etherpad.pad.chatarchive"); +import("etherpad.pad.model"); +import("etherpad.pad.padutils"); +import("etherpad.pad.padusers"); +import("etherpad.control.pad.pad_view_control"); +import("etherpad.control.pad.pad_changeset_control"); +import("etherpad.control.pad.pad_importexport_control"); +import("etherpad.collab.readonly_server"); + +import("dispatch.{Dispatcher,PrefixMatcher,DirMatcher,forward}"); + +jimport("java.lang.System.out.println"); + +var DISABLE_PAD_CREATION = false; + +function onStartup() { + sqlbase.createJSONTable("PAD_DIAGNOSTIC"); +} + +function onRequest() { + + // TODO: take a hard look at /ep/pad/FOO/BAR/ dispatching. + // Perhaps standardize on /ep/pad/<pad-id>/foo + if (request.path.indexOf('/ep/pad/auth/') == 0) { + if (request.isGet) { + return render_auth_get(); + } + if (request.isPost) { + return render_auth_post(); + } + } + + if (pro_utils.isProDomainRequest()) { + pro_quotas.perRequestBillingCheck(); + } + + var disp = new Dispatcher(); + disp.addLocations([ + [PrefixMatcher('/ep/pad/view/'), forward(pad_view_control)], + [PrefixMatcher('/ep/pad/changes/'), forward(pad_changeset_control)], + [PrefixMatcher('/ep/pad/impexp/'), forward(pad_importexport_control)], + [PrefixMatcher('/ep/pad/export/'), pad_importexport_control.renderExport] + ]); + return disp.dispatch(); +} + +//---------------------------------------------------------------- +// utils +//---------------------------------------------------------------- + +function getDefaultPadText() { + if (pro_utils.isProDomainRequest()) { + return pro_config.getConfig().defaultPadText; + } + return renderTemplateAsString("misc/pad_default.ejs", {padUrl: request.url.split("?", 1)[0]}); +} + +function assignName(pad, userId) { + if (padusers.isGuest(userId)) { + // use pad-specific name if possible + var userData = pad.getAuthorData(userId); + var nm = (userData && userData.name) || padusers.getUserName() || null; + + // don't let name guest typed in last once we've assigned a name + // for this pad, so the user can change it + delete getSession().guestDisplayName; + + return nm; + } + else { + return padusers.getUserName(); + } +} + +function assignColorId(pad, userId) { + // use pad-specific color if possible + var userData = pad.getAuthorData(userId); + if (userData && ('colorId' in userData)) { + return userData.colorId; + } + + // assign random unique color + function r(n) { + return Math.floor(Math.random() * n); + } + var colorsUsed = {}; + var users = collab_server.getConnectedUsers(pad); + var availableColors = []; + users.forEach(function(u) { + colorsUsed[u.colorId] = true; + }); + for (var i = 0; i < COLOR_PALETTE.length; i++) { + if (!colorsUsed[i]) { + availableColors.push(i); + } + } + if (availableColors.length > 0) { + return availableColors[r(availableColors.length)]; + } else { + return r(COLOR_PALETTE.length); + } +} + +function _getPrivs() { + return { + maxRevisions: quotas.getMaxSavedRevisionsPerPad() + }; +} + +//---------------------------------------------------------------- +// linkfile (a file that users can save that redirects them to +// a particular pad; auto-download) +//---------------------------------------------------------------- +function render_linkfile() { + var padId = request.params.padId; + + renderHtml("pad/pad_download_link.ejs", { + padId: padId + }); + + response.setHeader("Content-Disposition", "attachment; filename=\""+padId+".html\""); +} + +//---------------------------------------------------------------- +// newpad +//---------------------------------------------------------------- + +function render_newpad() { + var session = getSession(); + var padId; + + if (pro_utils.isProDomainRequest()) { + padId = pro_pad_db.getNextPadId(); + } else { + padId = randomUniquePadId(); + } + + session.instantCreate = padId; + response.redirect("/"+padId); +} + +// Tokbox +function render_newpad_xml_post() { + var localPadId; + if (pro_utils.isProDomainRequest()) { + localPadId = pro_pad_db.getNextPadId(); + } else { + localPadId = randomUniquePadId(); + } + // <RAFTER> + if (DISABLE_PAD_CREATION) { + if (! pro_utils.isProDomainRequest()) { + utils.render500(); + return; + } + } + // </RAFTER> + + padutils.accessPadLocal(localPadId, function(pad) { + if (!pad.exists()) { + pad.create(getDefaultPadText()); + } + }); + response.setContentType('text/plain; charset=utf-8'); + response.write([ + '<newpad>', + '<url>http://'+request.host+'/'+localPadId+'</url>', + '</newpad>' + ].join('\n')); +} + +//---------------------------------------------------------------- +// pad +//---------------------------------------------------------------- + +function _createIfNecessary(localPadId, pad) { + if (pad.exists()) { + delete getSession().instantCreate; + return; + } + // make sure localPadId is valid. + var validPadId = padutils.makeValidLocalPadId(localPadId); + if (localPadId != validPadId) { + response.redirect('/'+validPadId); + } + // <RAFTER> + if (DISABLE_PAD_CREATION) { + if (! pro_utils.isProDomainRequest()) { + response.redirect("/ep/pad/create?padId="+encodeURIComponent(localPadId)); + return; + } + } + // </RAFTER> + // tokbox may use createImmediately + if (request.params.createImmediately || getSession().instantCreate == localPadId) { + pad.create(getDefaultPadText()); + delete getSession().instantCreate; + return; + } + response.redirect("/ep/pad/create?padId="+encodeURIComponent(localPadId)); +} + +function _promptForMobileDevices(pad) { + // TODO: also work with blackbery and windows mobile and others + if (request.userAgent.isIPhone() && (!request.params.skipIphoneCheck)) { + renderHtml("pad/pad_iphone_body.ejs", {padId: pad.getLocalId()}); + response.stop(); + } +} + +function _checkPadQuota(pad) { + var numConnectedUsers = collab_server.getNumConnections(pad); + var maxUsersPerPad = quotas.getMaxSimultaneousPadEditors(pad.getId()); + + if (numConnectedUsers >= maxUsersPerPad) { + log.info("rendered-padfull"); + renderFramed('pad/padfull_body.ejs', + {maxUsersPerPad: maxUsersPerPad, padId: pad.getLocalId()}); + response.stop(); + } + + if (pne_utils.isPNE()) { + if (!licensing.canSessionUserJoin()) { + renderFramed('pad/total_users_exceeded.ejs', { + userQuota: licensing.getActiveUserQuota(), + activeUserWindowHours: licensing.getActiveUserWindowHours() + }); + response.stop(); + } + } +} + +function _checkIfDeleted(pad) { + // TODO: move to access control check on access? + if (pro_utils.isProDomainRequest()) { + pro_padmeta.accessProPad(pad.getId(), function(propad) { + if (propad.exists() && propad.isDeleted()) { + renderNoticeString("This pad has been deleted."); + response.stop(); + } + }); + } +} + +function render_pad(localPadId) { + var proTitle = null, documentBarTitle, initialPassword = null; + var isPro = isProDomainRequest(); + var userId = padusers.getUserId(); + + var opts = {}; + var globalPadId; + + if (isPro) { + pro_quotas.perRequestBillingCheck(); + } + + padutils.accessPadLocal(localPadId, function(pad) { + globalPadId = pad.getId(); + request.cache.globalPadId = globalPadId; + _createIfNecessary(localPadId, pad); + _promptForMobileDevices(pad); + _checkPadQuota(pad); + _checkIfDeleted(pad); + + if (request.params.inviteTo) { + getSession().nameGuess = request.params.inviteTo; + response.redirect('/'+localPadId); + } + var displayName; + if (request.params.displayName) { // tokbox + displayName = String(request.params.displayName); + } + else { + displayName = assignName(pad, userId); + } + + if (isProDomainRequest()) { + pro_padmeta.accessProPadLocal(localPadId, function(propad) { + proTitle = propad.getDisplayTitle(); + initialPassword = propad.getPassword(); + }); + } + documentBarTitle = (proTitle || "Public Pad"); + + var specialKey = request.params.specialKey || + (sessions.isAnEtherpadAdmin() ? collab_server.getSpecialKey('invisible') : + null); + if (request.params.fullScreen) { // tokbox, embedding + opts.fullScreen = true; + } + if (request.params.tokbox) { + opts.tokbox = true; + } + if (request.params.sidebar) { + opts.sidebar = Boolean(Number(request.params.sidebar)); + } + + helpers.addClientVars({ + padId: localPadId, + globalPadId: globalPadId, + userAgent: request.headers["User-Agent"], + collab_client_vars: collab_server.getCollabClientVars(pad), + debugEnabled: request.params.djs, + clientIp: request.clientAddr, + colorPalette: COLOR_PALETTE, + nameGuess: (getSession().nameGuess || null), + initialRevisionList: revisions.getRevisionList(pad), + serverTimestamp: +(new Date), + accountPrivs: _getPrivs(), + chatHistory: chatarchive.getRecentChatBlock(pad, 30), + numConnectedUsers: collab_server.getNumConnections(pad), + isProPad: isPro, + initialTitle: documentBarTitle, + initialPassword: initialPassword, + initialOptions: pad.getPadOptionsObj(), + userIsGuest: padusers.isGuest(userId), + userId: userId, + userName: displayName, + userColor: assignColorId(pad, userId), + specialKey: specialKey, + specialKeyTranslation: collab_server.translateSpecialKey(specialKey), + opts: opts + }); + }); + + var isProUser = (isPro && ! padusers.isGuest(userId)); + + var isFullWidth = false; + var hideSidebar = false; + var cookiePrefs = padutils.getPrefsCookieData(); + if (cookiePrefs) { + isFullWidth = !! cookiePrefs.fullWidth; + hideSidebar = !! cookiePrefs.hideSidebar; + } + if (opts.fullScreen) { + isFullWidth = true; + if (opts.tokbox) { + hideSidebar = true; + } + } + if ('sidebar' in opts) { + hideSidebar = ! opts.sidebar; + } + var bodyClass = (isFullWidth ? "fullwidth" : "limwidth")+ + " "+(isPro ? "propad" : "nonpropad")+" "+ + (isProUser ? "prouser" : "nonprouser"); + + var cookiePrefsToSet = {fullWidth:isFullWidth, hideSidebar:hideSidebar}; + helpers.addClientVars({cookiePrefsToSet: cookiePrefsToSet}); + + renderHtml("pad/pad_body2.ejs", + {localPadId:localPadId, + pageTitle:toHTML(proTitle || localPadId), + initialTitle:toHTML(documentBarTitle), + bodyClass: bodyClass, + hasOffice: hasOffice(), + isPro: isPro, + isProAccountHolder: isProUser, + account: getSessionProAccount(), // may be falsy + toHTML: toHTML, + prefs: {isFullWidth:isFullWidth, hideSidebar:hideSidebar}, + signinUrl: '/ep/account/sign-in?cont='+ + encodeURIComponent(request.url), + fullSuperdomain: pro_utils.getFullSuperdomainHost() + }); + return true; +} + +function render_create_get() { + var padId = request.params.padId; + // <RAFTER> + var template = (DISABLE_PAD_CREATION && ! pro_utils.isProDomainRequest()) ? + "pad/create_body_rafter.ejs" : + "pad/create_body.ejs"; + // </RAFTER> + renderFramed(template, {padId: padId, + fullSuperdomain: pro_utils.getFullSuperdomainHost()}); +} + +function render_create_post() { + var padId = request.params.padId; + getSession().instantCreate = padId; + response.redirect("/"+padId); +} + +//---------------------------------------------------------------- +// saverevision +//---------------------------------------------------------------- + +function render_saverevision_post() { + var padId = request.params.padId; + var savedBy = request.params.savedBy; + var savedById = request.params.savedById; + var revNum = request.params.revNum; + var privs = _getPrivs(); + padutils.accessPadLocal(padId, function(pad) { + if (! pad.exists()) { response.notFound(); } + var currentRevs = revisions.getRevisionList(pad); + if (currentRevs.length >= privs.maxRevisions) { + response.forbid(); + } + var savedRev = revisions.saveNewRevision(pad, savedBy, savedById, + revNum); + readonly_server.broadcastNewRevision(pad, savedRev); + response.setContentType('text/x-json'); + response.write(fastJSON.stringify(revisions.getRevisionList(pad))); + }); +} + +function render_saverevisionlabel_post() { + var userId = request.params.userId; + var padId = request.params.padId; + var revId = request.params.revId; + var newLabel = request.params.newLabel; + padutils.accessPadLocal(padId, function(pad) { + revisions.setLabel(pad, revId, userId, newLabel); + response.setContentType('text/x-json'); + response.write(fastJSON.stringify(revisions.getRevisionList(pad))); + }); +} + +function render_getrevisionatext_get() { + var padId = request.params.padId; + var revId = request.params.revId; + var result = null; + + var rev = padutils.accessPadLocal(padId, function(pad) { + var r = revisions.getStoredRevision(pad, revId); + var forWire = collab_server.getATextForWire(pad, r.revNum); + result = {atext:forWire.atext, apool:forWire.apool, + historicalAuthorData:forWire.historicalAuthorData}; + return r; + }, "r"); + + response.setContentType('text/plain; charset=utf-8'); + response.write(fastJSON.stringify(result)); +} + +//---------------------------------------------------------------- +// reconnect +//---------------------------------------------------------------- + +function _recordDiagnosticInfo(padId, diagnosticInfoJson) { + + var diagnosticInfo = {}; + try { + diagnosticInfo = fastJSON.parse(diagnosticInfoJson); + } catch (ex) { + log.warn("Error parsing diagnosticInfoJson: "+ex); + diagnosticInfo = {error: "error parsing JSON"}; + } + + // ignore userdups, unauth + if (diagnosticInfo.disconnectedMessage == "userdup" || + diagnosticInfo.disconnectedMessage == "unauth") { + return; + } + + var d = new Date(); + + diagnosticInfo.date = +d; + diagnosticInfo.strDate = String(d); + diagnosticInfo.clientAddr = request.clientAddr; + diagnosticInfo.padId = padId; + diagnosticInfo.headers = {}; + eachProperty(request.headers, function(k,v) { + diagnosticInfo.headers[k] = v; + }); + + var uid = diagnosticInfo.uniqueId; + + sqlbase.putJSON("PAD_DIAGNOSTIC", (diagnosticInfo.date)+"-"+uid, diagnosticInfo); + +} + +function recordMigratedDiagnosticInfo(objArray) { + objArray.forEach(function(obj) { + sqlbase.putJSON("PAD_DIAGNOSTIC", (obj.date)+"-"+obj.uniqueId, obj); + }); +} + +function render_reconnect() { + var localPadId = request.params.padId; + var globalPadId = padutils.getGlobalPadId(localPadId); + var userId = (padutils.getPrefsCookieUserId() || undefined); + var hasClientErrors = false; + var uniqueId; + try { + var obj = fastJSON.parse(request.params.diagnosticInfo); + uniqueId = obj.uniqueId; + errorMessage = obj.disconnectedMessage; + hasClientErrors = obj.collabDiagnosticInfo.errors.length > 0; + } catch (e) { + // guess it doesn't have errors. + } + + log.custom("reconnect", {globalPadId: globalPadId, userId: userId, + uniqueId: uniqueId, + hasClientErrors: hasClientErrors, + errorMessage: errorMessage }); + + try { + _recordDiagnosticInfo(globalPadId, request.params.diagnosticInfo); + } catch (ex) { + log.warn("Error recording diagnostic info: "+ex+" / "+request.params.diagnosticInfo); + } + + try { + _applyMissedChanges(localPadId, request.params.missedChanges); + } catch (ex) { + log.warn("Error applying missed changes: "+ex+" / "+request.params.missedChanges); + } + + response.redirect('/'+localPadId); +} + +/* posted asynchronously by the client as soon as reconnect dialogue appears. */ +function render_connection_diagnostic_info_post() { + var localPadId = request.params.padId; + var globalPadId = padutils.getGlobalPadId(localPadId); + var userId = (padutils.getPrefsCookieUserId() || undefined); + var hasClientErrors = false; + var uniqueId; + var errorMessage; + try { + var obj = fastJSON.parse(request.params.diagnosticInfo); + uniqueId = obj.uniqueId; + errorMessage = obj.disconnectedMessage; + hasClientErrors = obj.collabDiagnosticInfo.errors.length > 0; + } catch (e) { + // guess it doesn't have errors. + } + log.custom("disconnected_autopost", {globalPadId: globalPadId, userId: userId, + uniqueId: uniqueId, + hasClientErrors: hasClientErrors, + errorMessage: errorMessage}); + + try { + _recordDiagnosticInfo(globalPadId, request.params.diagnosticInfo); + } catch (ex) { + log.warn("Error recording diagnostic info: "+ex+" / "+request.params.diagnosticInfo); + } + response.setContentType('text/plain; charset=utf-8'); + response.write("OK"); +} + +function _applyMissedChanges(localPadId, missedChangesJson) { + var missedChanges; + try { + missedChanges = fastJSON.parse(missedChangesJson); + } catch (ex) { + log.warn("Error parsing missedChangesJson: "+ex); + return; + } + + padutils.accessPadLocal(localPadId, function(pad) { + if (pad.exists()) { + collab_server.applyMissedChanges(pad, missedChanges); + } + }); +} + +//---------------------------------------------------------------- +// feedback +//---------------------------------------------------------------- + +function render_feedback_post() { + var feedback = request.params.feedback; + var localPadId = request.params.padId; + var globalPadId = padutils.getGlobalPadId(localPadId); + var username = request.params.username; + var email = request.params.email; + var subject = 'EtherPad Feedback from '+request.clientAddr+' / '+globalPadId+' / '+username; + + if (feedback.indexOf("@") > 0) { + subject = "@ "+subject; + } + + feedback += "\n\n--\n"; + feedback += ("User Agent: "+request.headers['User-Agent'] + "\n"); + feedback += ("Session Referer: "+getSession().initialReferer + "\n"); + feedback += ("Email: "+email+"\n"); + + // log feedback + var userId = padutils.getPrefsCookieUserId(); + log.custom("feedback", { + globalPadId: globalPadId, + userId: userId, + email: email, + username: username, + feedback: request.params.feedback}); + + sendEmail( + 'feedback@pad.spline.inf.fu-berlin.de', + 'feedback@pad.spline.inf.fu-berlin.de', + subject, + {}, + feedback + ); + response.write("OK"); +} + +//---------------------------------------------------------------- +// emailinvite +//---------------------------------------------------------------- + +function render_emailinvite_post() { + var toEmails = String(request.params.toEmails).split(','); + var padId = String(request.params.padId); + var username = String(request.params.username); + var subject = String(request.params.subject); + var message = String(request.params.message); + + log.custom("padinvite", + {toEmails: toEmails, padId: padId, username: username, + subject: subject, message: message}); + + var fromAddr = '"EtherPad" <noreply@pad.spline.inf.fu-berlin.de>'; + // client enforces non-empty subject and message + var subj = '[EtherPad] '+subject; + var body = renderTemplateAsString('email/padinvite.ejs', + {body: message}); + var headers = {}; + var proAccount = getSessionProAccount(); + if (proAccount) { + headers['Reply-To'] = proAccount.email; + } + + response.setContentType('text/plain; charset=utf-8'); + try { + sendEmail(toEmails, fromAddr, subj, headers, body); + response.write("OK"); + } catch (e) { + logException(e); + response.setStatusCode(500); + response.write("Error"); + } +} + +//---------------------------------------------------------------- +// time-slider +//---------------------------------------------------------------- +function render_slider() { + var parts = request.path.split('/'); + var padOpaqueRef = parts[4]; + + helpers.addClientVars({padOpaqueRef:padOpaqueRef}); + + renderHtml("pad/padslider_body.ejs", { + // properties go here + }); + + return true; +} + +//---------------------------------------------------------------- +// auth +//---------------------------------------------------------------- + +function render_auth_get() { + var parts = request.path.split('/'); + var localPadId = parts[4]; + var errDiv; + if (getSession().padPassErr) { + errDiv = DIV({style: "border: 1px solid #fcc; background: #ffeeee; padding: 1em; margin: 1em 0;"}, + B(getSession().padPassErr)); + delete getSession().padPassErr; + } else { + errDiv = DIV(); + } + renderFramedHtml(function() { + return DIV({className: "fpcontent"}, + DIV({style: "margin: 1em;"}, + errDiv, + FORM({style: "border: 1px solid #ccc; padding: 1em; background: #fff6cc;", + action: request.path+'?'+request.query, + method: "post"}, + LABEL(B("Please enter the password required to access this pad:")), + BR(), BR(), + INPUT({type: "text", name: "password"}), INPUT({type: "submit", value: "Submit"}) + /*DIV(BR(), "Or ", A({href: '/ep/account/sign-in'}, "sign in"), ".")*/ + )), + DIV({style: "padding: 0 1em;"}, + P({style: "color: #444;"}, + "If you have forgotten a pad's password, contact your site administrator.", + " Site administrators can recover lost pad text through the \"Admin\" tab.") + ) + ); + }); + return true; +} + +function render_auth_post() { + var parts = request.path.split('/'); + var localPadId = parts[4]; + var domainId = domains.getRequestDomainId(); + if (!getSession().padPasswordAuth) { + getSession().padPasswordAuth = {}; + } + var currentPassword = pro_padmeta.accessProPadLocal(localPadId, function(propad) { + return propad.getPassword(); + }); + if (request.params.password == currentPassword) { + var globalPadId = padutils.getGlobalPadId(localPadId); + getSession().padPasswordAuth[globalPadId] = true; + } else { + getSession().padPasswordAuth[globalPadId] = false; + getSession().padPassErr = "Incorrect password."; + } + var cont = request.params.cont; + if (!cont) { + cont = '/'+localPadId; + } + response.redirect(cont); +} + +//---------------------------------------------------------------- +// chathistory +//---------------------------------------------------------------- + +function render_chathistory_get() { + var padId = request.params.padId; + var start = Number(request.params.start || 0); + var end = Number(request.params.end || 0); + var result = null; + + var rev = padutils.accessPadLocal(padId, function(pad) { + result = chatarchive.getChatBlock(pad, start, end); + }, "r"); + + response.setContentType('text/plain; charset=utf-8'); + response.write(fastJSON.stringify(result)); +} + diff --git a/trunk/etherpad/src/etherpad/control/pad/pad_importexport_control.js b/trunk/etherpad/src/etherpad/control/pad/pad_importexport_control.js new file mode 100644 index 0000000..b7e5f4d --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pad/pad_importexport_control.js @@ -0,0 +1,319 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("jsutils.arrayToSet"); +import("stringutils.{toHTML,md5}"); +import("stringutils"); +import("sync"); +import("varz"); + +import("etherpad.control.pad.pad_view_control.getRevisionInfo"); +import("etherpad.helpers"); +import("etherpad.importexport.importexport"); +import("etherpad.log"); +import("etherpad.pad.model"); +import("etherpad.pad.padutils"); +import("etherpad.pad.importhtml"); +import("etherpad.pad.exporthtml"); +import("etherpad.sessions"); +import("etherpad.sessions.getSession"); +import("etherpad.utils.{render404,renderFramedError}"); +import("etherpad.collab.server_utils"); + +function _log(obj) { + log.custom("import-export", obj); +} + +//--------------------------------------- +// utilities +//--------------------------------------- + +function _getPadTextBytes(padId, revNum) { + if (revNum === undefined) { + return null; + } + return padutils.accessPadLocal(padId, function(pad) { + if (pad.exists()) { + var txt = exporthtml.getPadPlainText(pad, revNum); + return (new java.lang.String(txt)).getBytes("UTF-8"); + } else { + return null; + } + }, 'r'); +} + +function _getPadHtmlBytes(padId, revNum, noDocType) { + if (revNum === undefined) { + return null; + } + var html = padutils.accessPadLocal(padId, function(pad) { + if (pad.exists()) { + return exporthtml.getPadHTMLDocument(pad, revNum, noDocType); + } + }); + if (html) { + return (new java.lang.String(html)).getBytes("UTF-8"); + } else { + return null; + } +} + +function _getFileExtension(fileName, def) { + if (fileName.lastIndexOf('.') > 0) { + return fileName.substr(fileName.lastIndexOf('.')+1); + } else { + return def; + } +} + +function _guessFileType(contentType, fileName) { + function _f(str) { return function() { return str; }} + var unchangedExtensions = + arrayToSet(['txt', 'htm', 'html', 'doc', 'docx', 'rtf', 'pdf', 'odt']); + var textExtensions = + arrayToSet(['js', 'scala', 'java', 'c', 'cpp', 'log', 'h', 'htm', 'html', 'css', 'php', 'xhtml', + 'dhtml', 'jsp', 'asp', 'sh', 'bat', 'pl', 'py']); + var contentTypes = { + 'text/plain': 'txt', + 'text/html': 'html', + 'application/msword': 'doc', + 'application/vnd.oasis.opendocument.text': 'odt', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx', + 'text/rtf': 'rtf', + 'application/pdf': 'pdf' + } + + var ext = _getFileExtension(fileName); + if (ext) { + if (unchangedExtensions[ext]) { + return ext; + } else if (textExtensions[ext]) { + return 'txt'; + } + } + if (contentType in contentTypes) { + return contentTypes[contentType] + } + // unknown type, nothing to return. + _log({type: "warning", error: "unknown-type", contentType: contentType, fileName: fileName}); +} + +function _noteExportFailure() { + varz.incrementInt("export-failed"); +} + +function _noteImportFailure() { + varz.incrementInt("import-failed"); +} + +//--------------------------------------- +// export +//--------------------------------------- + +// handles /ep/pad/export/* +function renderExport() { + var parts = request.path.split('/'); + var padId = server_utils.parseUrlId(parts[4]).localPadId; + var revisionId = parts[5]; + var rev = null; + var format = request.params.format || 'txt'; + + if (! request.cache.skipAccess) { + _log({type: "request", direction: "export", format: format}); + rev = getRevisionInfo(padId, revisionId); + if (! rev) { + render404(); + } + request.cache.skipAccess = true; + } + + var result = _exportToFormat(padId, revisionId, (rev || {}).revNum, format); + if (result === true) { + response.stop(); + } else { + renderFramedError(result); + } + return true; +} + +function _exportToFormat(padId, revisionId, revNum, format) { + var bytes = _doExportConversion(format, + function() { return _getPadTextBytes(padId, revNum); }, + function(noDocType) { return _getPadHtmlBytes(padId, revNum, noDocType); }); + if (! bytes) { + return "Unable to convert file for export... try a different format?" + } else if (typeof(bytes) == 'string') { + return bytes + } else { + response.setContentType(importexport.formats[format]); + response.setHeader("Content-Disposition", "attachment; filename=\""+padId+"-"+revisionId+"."+format+"\""); + response.writeBytes(bytes); + return true; + } +} + + +function _doExportConversion(format, getTextBytes, getHtmlBytes) { + if (! (format in importexport.formats)) { + return false; + } + var bytes; + var srcFormat; + + if (format == 'txt') { + bytes = getTextBytes(); + srcFormat = 'txt'; + } else { + bytes = getHtmlBytes(format == 'doc' || format == 'odt'); + srcFormat = 'html'; + } + if (bytes == null) { + bytes = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, 0); + } + + try { + var ret = importexport.convertFile(srcFormat, format, bytes); + if (typeof(ret) == 'string') { + _log({type: "error", error: "export-failed", format: format, message: ret}); + _noteExportFailure(); + return ret; + } + bytes = ret; + } catch (e) { + if (e.javaException instanceof org.mortbay.jetty.RetryRequest) { + throw e.javaException + } + if (e.javaException || e.rhinoException) { + net.appjet.oui.exceptionlog.apply(e.javaException || e.rhinoException); + } + bytes = null; + } + if (bytes == null || bytes.length == 0) { + _log({type: "error", error: "export-failed", format: format, message: ret}); + _noteExportFailure(); + return false; + } + return bytes; +} + +//--------------------------------------- +// import +//--------------------------------------- + +function _getImportInfo(key) { + var session = getSession(); + sync.callsyncIfTrue(session, function() { return ! ('importexport' in session) }, + function() { + session.importexport = {}; + }); + var tokens = session.importexport; + sync.callsyncIfTrue(tokens, function() { return ! (key in tokens) }, + function() { + tokens[key] = {}; + }); + return tokens[key]; +} + +function render_import() { + function _r(code) { + response.setContentType("text/html"); + response.write("<html><body><script>try{parent.document.domain}catch(e){document.domain=document.domain}\n"+code+"</script></body></html>"); + response.stop(); + } + + if (! request.isPost) { + response.stop(); + } + + var padId = decodeURIComponent(request.params.padId); + if (! padId) { + response.stop(); + } + + var file = request.files.file; + if (! file) { + _r('parent.pad.handleImportExportFrameCall("importFailed", "Please select a file to import.")'); + } + + var bytes = file.bytes; + var type = _guessFileType(file.contentType, file.filesystemName); + + _log({type: "request", direction: "import", format: type}); + + if (! type) { + type = _getFileExtension(file.filesystemName, "no file extension found"); + _r('parent.pad.handleImportExportFrameCall("importFailed", "'+importexport.errorUnsupported(type)+'")'); + } + + var token = md5(bytes); + var state = _getImportInfo(token); + state.bytes = bytes; + state.type = type; + + _r("parent.pad.handleImportExportFrameCall('importSuccessful', '"+token+"')"); +} + + +function render_import2() { + var token = request.params.token; + + function _r(txt) { + response.write(txt); + response.stop(); + } + + if (! token) { _r("fail"); } + + var state = _getImportInfo(token); + if (! state.type || ! state.bytes) { _r("fail"); } + + var newBytes; + try { + newBytes = importexport.convertFile(state.type, "html", state.bytes); + } catch (e) { + if (e.javaException instanceof org.mortbay.jetty.RetryRequest) { + throw e.javaException; + } + net.appjet.oui.exceptionlog.apply(e); + throw e; + } + + if (typeof(newBytes) == 'string') { + _log({type: "error", error: "import-failed", format: state.type, message: newBytes}); + _noteImportFailure(); + _r("msg:"+newBytes); + } + + if (! newBytes || newBytes.length == 0) { + _r("fail"); + } + + var newHTML; + try { + newHTML = String(new java.lang.String(newBytes, "UTF-8")); + } catch (e) { + _r("fail"); + } + + if (! request.params.padId) { _r("fail"); } + padutils.accessPadLocal(request.params.padId, function(pad) { + if (! pad.exists()) { + _r("fail"); + } + importhtml.setPadHTML(pad, newHTML); + }); + _r("ok"); +} diff --git a/trunk/etherpad/src/etherpad/control/pad/pad_view_control.js b/trunk/etherpad/src/etherpad/control/pad/pad_view_control.js new file mode 100644 index 0000000..0606d2c --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pad/pad_view_control.js @@ -0,0 +1,287 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.helpers"); +import("etherpad.pad.model"); +import("etherpad.pad.padusers"); +import("etherpad.pad.padutils"); +import("etherpad.pad.exporthtml"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_padmeta"); +import("etherpad.utils.*"); +import("etherpad.pad.revisions"); +import("stringutils.toHTML"); +import("etherpad.collab.server_utils.*"); +import("etherpad.collab.collab_server.buildHistoricalAuthorDataMapForPadHistory"); +import("etherpad.collab.collab_server.getATextForWire"); +import("etherpad.control.pad.pad_changeset_control.getChangesetInfo"); +import("etherpad.globals"); +import("fastJSON"); +import("etherpad.collab.ace.easysync2.Changeset"); +import("etherpad.collab.ace.linestylefilter.linestylefilter"); +import("etherpad.collab.ace.domline.domline"); + +//---------------------------------------------------------------- +// view (viewing a static revision of a pad) +//---------------------------------------------------------------- + +function onRequest() { + var parts = request.path.split('/'); + // TODO(kroo): create a mapping between padId and read-only id + var readOnlyIdOrLocalPadId = parts[4]; + var parseResult = parseUrlId(readOnlyIdOrLocalPadId); + var isReadOnly = parseResult.isReadOnly; + var viewId = parseResult.viewId; + var localPadId = parseResult.localPadId; + var globalPadId = parseResult.globalPadId; + var roPadId = parseResult.roPadId; + var revisionId = parts[5]; + + var rev = getRevisionInfo(localPadId, revisionId); + if (! rev) { + return false; + } + + if (request.params.pt == 1) { + var padText = padutils.accessPadLocal(localPadId, function(pad) { + return pad.getRevisionText(rev.revNum); + }, 'r'); + + response.setContentType('text/plain; charset=utf-8'); + response.write(padText); + } else { + var padContents, totalRevs, atextForWire, savedRevisions; + var supportsSlider; + padutils.accessPadLocal(localPadId, function(pad) { + padContents = [_getPadHTML(pad, rev.revNum), + pad.getRevisionText(rev.revNum)]; + totalRevs = pad.getHeadRevisionNumber(); + atextForWire = getATextForWire(pad, rev.revNum); + savedRevisions = revisions.getRevisionList(pad); + supportsSlider = pad.getSupportsTimeSlider(); + }, 'r'); + + var _add = function(dict, anotherdict) { + for(var key in anotherdict) { + dict[key] = anotherdict[key]; + } + return dict; + } + + var getAdaptiveChangesetsArray = function(array, start, granularity) { + array = array || []; + start = start || 0; + granularity = granularity || Math.pow(10, Math.floor(Math.log(totalRevs+1) / Math.log(10))); + var changeset = _add(getChangesetInfo(localPadId, start, totalRevs+1, granularity), { + start: start, + granularity: Math.floor(granularity) + }); + array.push(changeset); + if(changeset.actualEndNum != totalRevs+1 && granularity > 1) + getAdaptiveChangesetsArray(array, changeset.actualEndNum, Math.floor(granularity / 10)); + return array; + } + var initialChangesets = []; + if (supportsSlider) { + initialChangesets = getAdaptiveChangesetsArray( + [ + _add(getChangesetInfo(localPadId, Math.floor(rev.revNum / 1000)*1000, Math.floor(rev.revNum / 1000)*1000+1000, 100), { + start: Math.floor(rev.revNum / 1000)*1000, + granularity: 100 + }), + _add(getChangesetInfo(localPadId, Math.floor(rev.revNum / 100)*100, Math.floor(rev.revNum / 100)*100+100, 10), { + start: Math.floor(rev.revNum / 100)*100, + granularity: 10 + }), + _add(getChangesetInfo(localPadId, Math.floor(rev.revNum / 10)*10, Math.floor(rev.revNum / 10)*10+10, 1), { + start: Math.floor(rev.revNum / 10)*10, + granularity: 1 + })] + ); + } + + var zpad = function(str, length) { + str = str+""; + while(str.length < length) + str = '0'+str; + return str; + }; + var dateFormat = function(savedWhen) { + var date = new Date(savedWhen); + var month = zpad(date.getMonth()+1,2); + var day = zpad(date.getDate(),2); + var year = (date.getFullYear()); + var hours = zpad(date.getHours(),2); + var minutes = zpad(date.getMinutes(),2); + var seconds = zpad(date.getSeconds(),2); + return ([month,'/',day,'/',year,' ',hours,':',minutes,':',seconds].join("")); + }; + + var proTitle = null; + var initialPassword = null; + if (isProDomainRequest()) { + pro_padmeta.accessProPadLocal(localPadId, function(propad) { + proTitle = propad.getDisplayTitle(); + initialPassword = propad.getPassword(); + }); + } + var documentBarTitle = (proTitle || "Public Pad"); + + var padHTML = padContents[0]; + var padText = padContents[1]; + + var historicalAuthorData = padutils.accessPadLocal(localPadId, function(pad) { + return buildHistoricalAuthorDataMapForPadHistory(pad); + }, 'r'); + + helpers.addClientVars({ + viewId: viewId, + initialPadContents: padText, + revNum: rev.revNum, + totalRevs: totalRevs, + initialChangesets: initialChangesets, + initialStyledContents: atextForWire, + savedRevisions: savedRevisions, + currentTime: rev.timestamp, + sliderEnabled: (!appjet.cache.killSlider) && request.params.slider != 0, + supportsSlider: supportsSlider, + historicalAuthorData: historicalAuthorData, + colorPalette: globals.COLOR_PALETTE, + padIdForUrl: readOnlyIdOrLocalPadId, + fullWidth: request.params.fullScreen == 1, + disableRightBar: request.params.sidebar == 0, + }); + + var userId = padusers.getUserId(); + var isPro = isProDomainRequest(); + var isProUser = (isPro && ! padusers.isGuest(userId)); + + var bodyClass = ["limwidth", + (isPro ? "propad" : "nonpropad"), + (isProUser ? "prouser" : "nonprouser")].join(" "); + + renderHtml("pad/padview_body.ejs", { + bodyClass: bodyClass, + isPro: isPro, + isProAccountHolder: isProUser, + account: pro_accounts.getSessionProAccount(), + signinUrl: '/ep/account/sign-in?cont='+ + encodeURIComponent(request.url), + padId: readOnlyIdOrLocalPadId, + padTitle: documentBarTitle, + rlabel: rev.label, + padHTML: padHTML, + padText: padText, + savedBy: rev.savedBy, + savedIp: rev.ip, + savedWhen: rev.timestamp, + toHTML: toHTML, + revisionId: revisionId, + dateFormat: dateFormat(rev.timestamp), + readOnly: isReadOnly, + roPadId: roPadId, + hasOffice: hasOffice() + }); + } + + return true; +} + +function getRevisionInfo(localPadId, revisionId) { + var rev = padutils.accessPadLocal(localPadId, function(pad) { + if (!pad.exists()) { + return null; + } + var r; + if (revisionId == "latest") { + // a "fake" revision for HEAD + var headRevNum = pad.getHeadRevisionNumber(); + r = { + revNum: headRevNum, + label: "Latest text of pad "+localPadId, + savedBy: null, + savedIp: null, + timestamp: +pad.getRevisionDate(headRevNum) + }; + } else if (revisionId == "autorecover") { + var revNum = _findLastGoodRevisionInPad(pad); + r = { + revNum: revNum, + label: "Auto-recovered text of pad "+localPadId, + savedBy: null, + savedIp: null, + timestamp: +pad.getRevisionDate(revNum) + }; + } else if(revisionId.indexOf("rev.") === 0) { + var revNum = parseInt(revisionId.split(".")[1]); + var latest = pad.getHeadRevisionNumber(); + if(revNum > latest) + revNum = latest; + r = { + revNum: revNum, + label: "Version " + revNum, + savedBy: null, + savedIp: null, + timestamp: +pad.getRevisionDate(revNum) + } + + } else { + r = revisions.getStoredRevision(pad, revisionId); + } + if (!r) { + return null; + } + return r; + }, "r"); + return rev; +} + +function _findLastGoodRevisionInPad(pad) { + var revNum = pad.getHeadRevisionNumber(); + function valueOrNullOnError(f) { + try { return f(); } catch (e) { return null; } + } + function isAcceptable(strOrNull) { + return (strOrNull && strOrNull.length > 20); + } + while (revNum > 0 && + ! isAcceptable(valueOrNullOnError(function() { return pad.getRevisionText(revNum); }))) { + revNum--; + } + return revNum; +} + +function _getPadHTML(pad, revNum) { + var atext = pad.getInternalRevisionAText(revNum); + var textlines = Changeset.splitTextLines(atext.text); + var alines = Changeset.splitAttributionLines(atext.attribs, + atext.text); + + var pieces = []; + var apool = pad.pool(); + for(var i=0;i<textlines.length;i++) { + var line = textlines[i]; + var aline = alines[i]; + var emptyLine = (line == '\n'); + var domInfo = domline.createDomLine(! emptyLine, true); + linestylefilter.populateDomLine(line, aline, apool, domInfo); + domInfo.prepareForAdd(); + var node = domInfo.node; + pieces.push('<div class="', node.className, '">', + node.innerHTML, '</div>\n'); + } + return pieces.join(''); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/control/pne_manual_control.js b/trunk/etherpad/src/etherpad/control/pne_manual_control.js new file mode 100644 index 0000000..0dd65f8 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pne_manual_control.js @@ -0,0 +1,75 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("funhtml.*"); + +import("etherpad.utils.*"); +import("etherpad.globals.*"); + +function onRequest() { + var p = request.path.split('/')[3]; + if (!p) { + p = "main"; + } + if (_getTitle(p)) { + _renderManualPage(p); + return true; + } else { + return false; + } +} + +function _getTitle(t) { + var titles = { + 'main': " ", + 'installation-guide': "Installation Guide", + 'upgrade-guide': "Upgrade Guide", + 'configuration-guide': "Configuration Guide", + 'troubleshooting': "Troubleshooting", + 'faq': "FAQ", + 'changelog': "ChangeLog" + }; + return titles[t]; +} + +function _renderTopnav(p) { + var d = DIV({className: "pne-manual-topnav"}); + if (p != "main") { + d.push(A({href: '/ep/pne-manual/'}, "PNE Manual"), + " > ", + _getTitle(p)); + } + return d; +} + +function _renderManualPage(p, data) { + data = (data || {}); + data.pneVersion = PNE_RELEASE_VERSION; + + function getContent() { + return renderTemplateAsString('pne-manual/'+p+'.ejs', data); + } + renderFramed('pne-manual/manual-template.ejs', { + getContent: getContent, + renderTopnav: function() { return _renderTopnav(p); }, + title: _getTitle(p), + id: p, + }); + return true; +} + + + diff --git a/trunk/etherpad/src/etherpad/control/pne_tracker_control.js b/trunk/etherpad/src/etherpad/control/pne_tracker_control.js new file mode 100644 index 0000000..ee36645 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pne_tracker_control.js @@ -0,0 +1,48 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("image"); +import("blob"); +import("sqlbase.sqlobj"); +import("jsutils.*"); + +function render_t() { + var data = { + date: new Date(), + remoteIp: request.clientAddr + }; + if (request.params.k) { + data.keyHash = request.params.k; + } + var found = false; + eachProperty(request.params, function(name, value) { + if (name != "k") { + data.name = name; + data.value = value; + found = true; + } + }); + if (found) { + sqlobj.insert('pne_tracking_data', data); + } + + // serve a 1x1 white image + if (!appjet.cache.pneTrackingImage) { + appjet.cache.pneTrackingImage = image.solidColorImageBlob(1, 1, "ffffff"); + } + blob.serveBlob(appjet.cache.pneTrackingImage); +} + diff --git a/trunk/etherpad/src/etherpad/control/pro/account_control.js b/trunk/etherpad/src/etherpad/control/pro/account_control.js new file mode 100644 index 0000000..031dbe6 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pro/account_control.js @@ -0,0 +1,369 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("stringutils"); +import("stringutils.*"); +import("funhtml.*"); +import("email.sendEmail"); +import("cache_utils.syncedWithCache"); + +import("etherpad.helpers"); +import("etherpad.utils.*"); +import("etherpad.sessions.getSession"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_accounts.getSessionProAccount"); +import("etherpad.pro.domains"); +import("etherpad.pro.pro_utils"); +import("etherpad.pro.pro_account_auto_signin"); +import("etherpad.pro.pro_config"); +import("etherpad.pad.pad_security"); +import("etherpad.pad.padutils"); +import("etherpad.pad.padusers"); +import("etherpad.collab.collab_server"); + +function onRequest() { + if (!getSession().tempFormData) { + getSession().tempFormData = {}; + } + + return false; // path not handled here +} + +//-------------------------------------------------------------------------------- +// helpers +//-------------------------------------------------------------------------------- + +function _redirOnError(m, clearQuery) { + if (m) { + getSession().accountFormError = m; + + var dest = request.url; + if (clearQuery) { + dest = request.path; + } + response.redirect(dest); + } +} + +function setSigninNotice(m) { + getSession().accountSigninNotice = m; +} + +function setSessionError(m) { + getSession().accountFormError = m; +} + +function _topDiv(id, name) { + var m = getSession()[name]; + if (m) { + delete getSession()[name]; + return DIV({id: id}, m); + } else { + return ''; + } +} + +function _messageDiv() { return _topDiv('account-message', 'accountMessage'); } +function _errorDiv() { return _topDiv('account-error', 'accountFormError'); } +function _signinNoticeDiv() { return _topDiv('signin-notice', 'accountSigninNotice'); } + +function _renderTemplate(name, data) { + data.messageDiv = _messageDiv; + data.errorDiv = _errorDiv; + data.signinNotice = _signinNoticeDiv; + data.tempFormData = getSession().tempFormData; + renderFramed('pro/account/'+name+'.ejs', data); +} + +//---------------------------------------------------------------- +// /ep/account/ +//---------------------------------------------------------------- + +function render_main_get() { + _renderTemplate('my-account', { + account: getSessionProAccount(), + changePass: getSession().changePass + }); +} + +function render_update_info_get() { + response.redirect('/ep/account/'); +} + +function render_update_info_post() { + var fullName = request.params.fullName; + var email = trim(request.params.email); + + getSession().tempFormData.email = email; + getSession().tempFormData.fullName = fullName; + + _redirOnError(pro_accounts.validateEmail(email)); + _redirOnError(pro_accounts.validateFullName(fullName)); + + pro_accounts.setEmail(getSessionProAccount(), email); + pro_accounts.setFullName(getSessionProAccount(), fullName); + + getSession().accountMessage = "Info updated."; + response.redirect('/ep/account/'); +} + +function render_update_password_get() { + response.redirect('/ep/account/'); +} + +function render_update_password_post() { + var password = request.params.password; + var passwordConfirm = request.params.passwordConfirm; + + if (password != passwordConfirm) { _redirOnError('Passwords did not match.'); } + + _redirOnError(pro_accounts.validatePassword(password)); + + pro_accounts.setPassword(getSessionProAccount(), password); + + if (getSession().changePass) { + delete getSession().changePass; + response.redirect('/'); + } + + getSession().accountMessage = "Password updated."; + response.redirect('/ep/account/'); +} + +//-------------------------------------------------------------------------------- +// signin/signout +//-------------------------------------------------------------------------------- + +function render_sign_in_get() { + if (request.params.uid && request.params.tp) { + var m = pro_accounts.authenticateTempSignIn(Number(request.params.uid), request.params.tp); + if (m) { + getSession().accountFormError = m; + response.redirect('/ep/account/'); + } + } + if (request.params.instantSigninKey) { + _attemptInstantSignin(request.params.instantSigninKey); + } + if (getSession().recentlySignedOut && getSession().accountFormError) { + delete getSession().accountFormError; + delete getSession().recentlySignedOut; + } + // Note: must check isAccountSignedIn before calling checkAutoSignin()! + if (pro_accounts.isAccountSignedIn()) { + _redirectToPostSigninDestination(); + } + pro_account_auto_signin.checkAutoSignin(); + var domainRecord = domains.getRequestDomainRecord(); + var showGuestBox = false; + if (request.params.guest && request.params.padId) { + showGuestBox = true; + } + _renderTemplate('signin', { + domain: pro_utils.getFullProDomain(), + siteName: toHTML(pro_config.getConfig().siteName), + email: getSession().tempFormData.email || "", + password: getSession().tempFormData.password || "", + rememberMe: getSession().tempFormData.rememberMe || false, + showGuestBox: showGuestBox, + localPadId: request.params.padId + }); +} + +function _attemptInstantSignin(key) { + // See src/etherpad/control/global_pro_account_control.js + var email = null; + var password = null; + syncedWithCache('global_signin_passwords', function(c) { + if (c[key]) { + email = c[key].email; + password = c[key].password; + } + delete c[key]; + }); + getSession().tempFormData.email = email; + _redirOnError(pro_accounts.authenticateSignIn(email, password), true); +} + +function render_sign_in_post() { + var email = trim(request.params.email); + var password = request.params.password; + + getSession().tempFormData.email = email; + getSession().tempFormData.rememberMe = request.params.rememberMe; + + _redirOnError(pro_accounts.authenticateSignIn(email, password)); + pro_account_auto_signin.setAutoSigninCookie(request.params.rememberMe); + _redirectToPostSigninDestination(); +} + +function render_guest_sign_in_get() { + var localPadId = request.params.padId; + var domainId = domains.getRequestDomainId(); + var globalPadId = padutils.makeGlobalId(domainId, localPadId); + var userId = padusers.getUserId(); + + pro_account_auto_signin.checkAutoSignin(); + pad_security.clearKnockStatus(userId, globalPadId); + + _renderTemplate('signin-guest', { + localPadId: localPadId, + errorMessage: getSession().guestAccessError, + siteName: toHTML(pro_config.getConfig().siteName), + guestName: padusers.getUserName() || "" + }); +} + +function render_guest_sign_in_post() { + function _err(m) { + if (m) { + getSession().guestAccessError = m; + response.redirect(request.url); + } + } + var displayName = request.params.guestDisplayName; + var localPadId = request.params.localPadId; + if (!(displayName && displayName.length > 0)) { + _err("Please enter a display name"); + } + getSession().guestDisplayName = displayName; + response.redirect('/ep/account/guest-knock?padId='+encodeURIComponent(localPadId)+ + "&guestDisplayName="+encodeURIComponent(displayName)); +} + +function render_guest_knock_get() { + var localPadId = request.params.padId; + helpers.addClientVars({ + localPadId: localPadId, + guestDisplayName: request.params.guestDisplayName, + padUrl: "http://"+httpHost(request.host)+"/"+localPadId + }); + _renderTemplate('guest-knock', {}); +} + +function render_guest_knock_post() { + var localPadId = request.params.padId; + var displayName = request.params.guestDisplayName; + var domainId = domains.getRequestDomainId(); + var globalPadId = padutils.makeGlobalId(domainId, localPadId); + var userId = padusers.getUserId(); + + response.setContentType("text/plain; charset=utf-8"); + // has the knock already been answsered? + var currentAnswer = pad_security.getKnockAnswer(userId, globalPadId); + if (currentAnswer) { + response.write(currentAnswer); + } else { + collab_server.guestKnock(globalPadId, userId, displayName); + response.write("wait"); + } +} + +function _redirectToPostSigninDestination() { + var cont = request.params.cont; + if (!cont) { cont = '/'; } + response.redirect(cont); +} + +function render_sign_out() { + pro_account_auto_signin.setAutoSigninCookie(false); + pro_accounts.signOut(); + delete getSession().padPasswordAuth; + getSession().recentlySignedOut = true; + response.redirect("/"); +} + +//-------------------------------------------------------------------------------- +// create-admin-account (eepnet only) +//-------------------------------------------------------------------------------- + +function render_create_admin_account_get() { + if (pro_accounts.doesAdminExist()) { + renderFramedError("An admin account already exists on this domain."); + response.stop(); + } + _renderTemplate('create-admin-account', {}); +} + +function render_create_admin_account_post() { + var email = trim(request.params.email); + var password = request.params.password; + var passwordConfirm = request.params.passwordConfirm; + var fullName = request.params.fullName; + + getSession().tempFormData.email = email; + getSession().tempFormData.fullName = fullName; + + if (password != passwordConfirm) { _redirOnError('Passwords did not match.'); } + + _redirOnError(pro_accounts.validateEmail(email)); + _redirOnError(pro_accounts.validateFullName(fullName)); + _redirOnError(pro_accounts.validatePassword(password)); + + pro_accounts.createNewAccount(null, fullName, email, password, true); + + var u = pro_accounts.getAccountByEmail(email, null); + + // TODO: should we send a welcome email here? + //pro_accounts.sendWelcomeEmail(u); + + _redirOnError(pro_accounts.authenticateSignIn(email, password)); + + response.redirect("/"); +} + + +//-------------------------------------------------------------------------------- +// forgot password +//-------------------------------------------------------------------------------- + +function render_forgot_password_get() { + if (request.params.instantSubmit && request.params.email) { + render_forgot_password_post(); + } else { + _renderTemplate('forgot-password', { + email: getSession().tempFormData.email || "" + }); + } +} + +function render_forgot_password_post() { + var email = trim(request.params.email); + + getSession().tempFormData.email = email; + + var u = pro_accounts.getAccountByEmail(email, null); + if (!u) { + _redirOnError("Account not found: "+email); + } + + var tempPass = stringutils.randomString(10); + pro_accounts.setTempPassword(u, tempPass); + + var subj = "EtherPad: Request to reset your password on "+request.domain; + var body = renderTemplateAsString('pro/account/forgot-password-email.ejs', { + account: u, + recoverUrl: pro_accounts.getTempSigninUrl(u, tempPass) + }); + var fromAddr = pro_utils.getEmailFromAddr(); + sendEmail(u.email, fromAddr, subj, {}, body); + + getSession().accountMessage = "An email has been sent to "+u.email+" with instructions to reset the password."; + response.redirect(request.path); +} + + + diff --git a/trunk/etherpad/src/etherpad/control/pro/admin/account_manager_control.js b/trunk/etherpad/src/etherpad/control/pro/admin/account_manager_control.js new file mode 100644 index 0000000..8f93b2e --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pro/admin/account_manager_control.js @@ -0,0 +1,260 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("funhtml.*"); +import("stringutils"); +import("stringutils.*"); +import("email.sendEmail"); + +import("etherpad.globals.*"); +import("etherpad.utils.*"); +import("etherpad.sessions.getSession"); + +import("etherpad.control.pro.admin.pro_admin_control"); + +import("etherpad.pne.pne_utils"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_accounts.getSessionProAccount"); +import("etherpad.pro.pro_utils"); +import("etherpad.pro.pro_config"); +import("etherpad.pro.domains"); +import("etherpad.billing.team_billing"); + +jimport("java.lang.System.out.println"); + +function _err(m) { + if (m) { + getSession().accountManagerError = m; + response.redirect(request.path); + } +} + +function _renderTopDiv(mid, htmlId) { + var m = getSession()[mid]; + if (m) { + delete getSession()[mid]; + return DIV({id: htmlId}, m); + } else { + return ''; + } +} + +function _errorDiv() { return _renderTopDiv('accountManagerError', 'error-message'); } +function _messageDiv() { return _renderTopDiv('accountManagerMessage', 'message'); } +function _warningDiv() { return _renderTopDiv('accountManagerWarning', 'warning'); } + +function onRequest() { + var parts = request.path.split('/'); + + function dispatchAccountAction(action, handlerGet, handlerPost) { + if ((parts[4] == action) && (isNumeric(parts[5]))) { + if (request.isGet) { handlerGet(+parts[5]); } + if (request.isPost) { handlerPost(+parts[5]); } + return true; + } + return false; + } + + if (dispatchAccountAction('account', render_account_get, render_account_post)) { + return true; + } + if (dispatchAccountAction('delete-account', render_delete_account_get, render_delete_account_post)) { + return true; + }; + + return false; +} + +function render_main() { + var accountList = pro_accounts.listAllDomainAccounts(); + pro_admin_control.renderAdminPage('account-manager', { + accountList: accountList, + messageDiv: _messageDiv, + warningDiv: _warningDiv + }); +} + +function render_new_get() { + pro_admin_control.renderAdminPage('new-account', { + oldData: getSession().accountManagerFormData || {}, + stringutils: stringutils, + errorDiv: _errorDiv + }); +} + +function _ensureBillingOK() { + var activeAccounts = pro_accounts.getCachedActiveCount(domains.getRequestDomainId()); + if (activeAccounts < PRO_FREE_ACCOUNTS) { + return; + } + + var status = team_billing.getDomainStatus(domains.getRequestDomainId()); + if (!((status == team_billing.CURRENT) + || (status == team_billing.PAST_DUE))) { + _err(SPAN( + "A payment profile is required to create more than ", PRO_FREE_ACCOUNTS, + " accounts. ", + A({href: "/ep/admin/billing/", id: "billinglink"}, "Manage billing"))); + } +} + +function render_new_post() { + if (request.params.cancel) { + response.redirect('/ep/admin/account-manager/'); + } + + _ensureBillingOK(); + + var fullName = request.params.fullName; + var email = trim(request.params.email); + var tempPass = request.params.tempPass; + var makeAdmin = !!request.params.makeAdmin; + + getSession().accountManagerFormData = { + fullName: fullName, + email: email, + tempPass: tempPass, + makeAdmin: makeAdmin + }; + + // validation + if (!tempPass) { + tempPass = stringutils.randomString(6); + } + + _err(pro_accounts.validateEmail(email)); + _err(pro_accounts.validateFullName(fullName)); + _err(pro_accounts.validatePassword(tempPass)); + + var existingAccount = pro_accounts.getAccountByEmail(email, null); + if (existingAccount) { + _err("There is already a account with that email address."); + } + + pro_accounts.createNewAccount(null, fullName, email, tempPass, makeAdmin); + var account = pro_accounts.getAccountByEmail(email, null); + + pro_accounts.setTempPassword(account, tempPass); + sendWelcomeEmail(account, tempPass); + + delete getSession().accountManagerFormData; + getSession().accountManagerMessage = "Account "+fullName+" ("+email+") created successfully."; + response.redirect('/ep/admin/account-manager/'); +} + +function sendWelcomeEmail(account, tempPass) { + var subj = "Welcome to EtherPad on "+pro_utils.getFullProDomain()+"!"; + var toAddr = account.email; + var fromAddr = pro_utils.getEmailFromAddr(); + + var body = renderTemplateAsString('pro/account/account-welcome-email.ejs', { + account: account, + adminAccount: getSessionProAccount(), + signinLink: pro_accounts.getTempSigninUrl(account, tempPass), + toEmail: toAddr, + siteName: pro_config.getConfig().siteName + }); + try { + sendEmail(toAddr, fromAddr, subj, {}, body); + } catch (ex) { + var d = DIV(); + d.push(P("Warning: unable to send welcome email.")); + if (pne_utils.isPNE()) { + d.push(P("Perhaps you have not ", + A({href: '/ep/admin/pne-config'}, "Configured SMTP on this server", "?"))); + } + getSession().accountManagerWarning = d; + } +} + +// Managing a single account. +function render_account_get(accountId) { + var account = pro_accounts.getAccountById(accountId); + if (!account) { + response.write("Account not found."); + return true; + } + pro_admin_control.renderAdminPage('manage-account', { + account: account, + errorDiv: _errorDiv, + warningDiv: _warningDiv + }); +} + +function render_account_post(accountId) { + if (request.params.cancel) { + response.redirect('/ep/admin/account-manager/'); + } + var newFullName = request.params.newFullName; + var newEmail = request.params.newEmail; + var newIsAdmin = !!request.params.newIsAdmin; + + _err(pro_accounts.validateEmail(newEmail)); + _err(pro_accounts.validateFullName(newFullName)); + + if ((!newIsAdmin) && (accountId == getSessionProAccount().id)) { + _err("You cannot remove your own administrator privileges."); + } + + var account = pro_accounts.getAccountById(accountId); + if (!account) { + response.write("Account not found."); + return true; + } + + pro_accounts.setEmail(account, newEmail); + pro_accounts.setFullName(account, newFullName); + pro_accounts.setIsAdmin(account, newIsAdmin); + + getSession().accountManageMessage = "Info updated."; + response.redirect('/ep/admin/account-manager/'); +} + +function render_delete_account_get(accountId) { + var account = pro_accounts.getAccountById(accountId); + if (!account) { + response.write("Account not found."); + return true; + } + pro_admin_control.renderAdminPage('delete-account', { + account: account, + errorDiv: _errorDiv + }); +} + +function render_delete_account_post(accountId) { + if (request.params.cancel) { + response.redirect("/ep/admin/account-manager/account/"+accountId); + } + + if (accountId == getSessionProAccount().id) { + getSession().accountManagerError = "You cannot delete your own account."; + response.redirect("/ep/admin/account-manager/account/"+accountId); + } + + var account = pro_accounts.getAccountById(accountId); + if (!account) { + response.write("Account not found."); + return true; + } + + pro_accounts.setDeleted(account); + getSession().accountManagerMessage = "The account "+account.fullName+" <"+account.email+"> has been deleted."; + response.redirect("/ep/admin/account-manager/"); +} + + + diff --git a/trunk/etherpad/src/etherpad/control/pro/admin/license_manager_control.js b/trunk/etherpad/src/etherpad/control/pro/admin/license_manager_control.js new file mode 100644 index 0000000..ca6d6a6 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pro/admin/license_manager_control.js @@ -0,0 +1,128 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("fileutils.writeRealFile"); +import("stringutils"); + +import("etherpad.licensing"); +import("etherpad.sessions.getSession"); +import("etherpad.utils.*"); +import("etherpad.pne.pne_utils"); + +import("etherpad.control.pro.admin.pro_admin_control"); + +jimport("java.lang.System.out.println"); + +//---------------------------------------------------------------- +// license manager +//---------------------------------------------------------------- + +function getPath() { + return '/ep/admin/pne-license-manager/'; +} + +function _getTemplateData(data) { + var licenseInfo = licensing.getLicense(); + data.licenseInfo = licenseInfo; + data.isUnlicensed = !licenseInfo; + data.isEvaluation = licensing.isEvaluation(); + data.isExpired = licensing.isExpired(); + data.isTooOld = licensing.isVersionTooOld(); + data.errorMessage = (getSession().errorMessage || null); + data.runningVersionString = pne_utils.getVersionString(); + data.licenseVersionString = licensing.getVersionString(); + return data; +} + +function render_main_get() { + licensing.reloadLicense(); + var licenseInfo = licensing.getLicense(); + if (!licenseInfo || licensing.isExpired()) { + response.redirect(getPath()+'edit'); + } + + pro_admin_control.renderAdminPage('pne-license-manager', + _getTemplateData({edit: false})); +} + +function render_edit_get() { + licensing.reloadLicense(); + + if (request.params.btn) { response.redirect(request.path); } + + var licenseInfo = licensing.getLicense(); + var oldData = getSession().oldLicenseData; + if (!oldData) { + oldData = {}; + if (licenseInfo) { + oldData.orgName = licenseInfo.organizationName; + oldData.personName = licenseInfo.personName; + } + } + + pro_admin_control.renderAdminPage('pne-license-manager', + _getTemplateData({edit: true, oldData: oldData})); + + delete getSession().errorMessage; +} + +function render_edit_post() { + pne_utils.enableTrackingAgain(); + + function _trim(s) { + if (!s) { return ''; } + return stringutils.trim(s); + } + function _clean(s) { + s = s.replace(/\W/g, ''); + s = s.replace(/\+/g, ''); + return s; + } + + if (request.params.cancel) { + delete getSession().oldLicenseData; + response.redirect(getPath()); + } + + var personName = _trim(request.params.personName); + var orgName = _trim(request.params.orgName); + var licenseString = _clean(request.params.licenseString); + + getSession().oldLicenseData = { + personName: personName, orgName: orgName, licenseString: licenseString}; + + var key = [personName,orgName,licenseString].join(":"); + println("validating key [ "+key+" ]"); + + if (!licensing.isValidKey(key)) { + getSession().errorMessage = "Invalid License Key"; + response.redirect(request.path); + } + + // valid key. write to disk. + var writeSuccess = false; + try { + println("writing key file: ./data/license.key"); + writeRealFile("./data/license.key", key); + writeSuccess = true; + } catch (ex) { + println("exception: "+ex); + getSession().errorMessage = "Failed to write key to disk. (Do you have permission to write ./data/license.key ?)."; + } + response.redirect(getPath()); +} + + diff --git a/trunk/etherpad/src/etherpad/control/pro/admin/pro_admin_control.js b/trunk/etherpad/src/etherpad/control/pro/admin/pro_admin_control.js new file mode 100644 index 0000000..f9ce179 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pro/admin/pro_admin_control.js @@ -0,0 +1,283 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("stringutils"); +import("funhtml.*"); +import("dispatch.{Dispatcher,DirMatcher,forward}"); + +import("etherpad.licensing"); +import("etherpad.control.admincontrol"); +import("etherpad.control.pro.admin.license_manager_control"); +import("etherpad.control.pro.admin.account_manager_control"); +import("etherpad.control.pro.admin.pro_config_control"); +import("etherpad.control.pro.admin.team_billing_control"); + +import("etherpad.pad.padutils"); + +import("etherpad.admin.shell"); +import("etherpad.sessions"); +import("etherpad.sessions.getSession"); + +import("etherpad.pne.pne_utils"); +import("etherpad.pro.pro_accounts"); +import("etherpad.utils.*"); + +//---------------------------------------------------------------- + +var _pathPrefix = '/ep/admin/'; + +var _PRO = 1; +var _PNE_ONLY = 2; +var _ONDEMAND_ONLY = 3; + +function _getLeftnavItems() { + var nav = [ + _PRO, [ + [_PRO, null, "Admin"], + [_PNE_ONLY, "pne-dashboard", "Server Dashboard"], + [_PNE_ONLY, "pne-license-manager/", "Manage License"], + [_PRO, "account-manager/", "Manage Accounts"], + [_PRO, "recover-padtext", "Recover Pad Text"], + [_PRO, null, "Configuration"], + [_PRO, [[_PNE_ONLY, "pne-config", "Private Server Configuration"], + [_PRO, "pro-config", "Application Configuration"]]], + [_PNE_ONLY, null, "Documentation"], + [_PNE_ONLY, "/ep/pne-manual/", "Administrator's Manual"], + ] + ]; + return nav; +} + +function renderAdminLeftNav() { + function _make(x) { + if ((x[0] == _PNE_ONLY) && !pne_utils.isPNE()) { + return null; + } + if ((x[0] == _ONDEMAND_ONLY) && pne_utils.isPNE()) { + return null; + } + + if (x[1] instanceof Array) { + return _makelist(x[1]); + } else { + return _makeitem(x); + } + } + var selected; + function _makeitem(x) { + if (x[1]) { + var p = x[1]; + if (x[1].charAt(0) != '/') { + p = _pathPrefix+p; + } + var li = LI(A({href: p}, x[2])); + if (stringutils.startsWith(request.path, p)) { + // select the longest prefix match. + if (! selected || p.length > selected.path.length) { + selected = {path: p, li: li}; + } + } + return li; + } else { + return LI(DIV({className: 'leftnav-title'}, x[2])); + } + } + function _makelist(x) { + var ul = UL(); + x.forEach(function(y) { + var t = _make(y); + if (t) { ul.push(t); } + }); + return ul; + } + var d = DIV(_make(_getLeftnavItems())); + if (selected) { + selected.li.attribs.className = "selected"; + } + // leftnav looks stupid when it's not very tall. + for (var i = 0; i < 10; i++) { d.push(BR()); } + return d; +} + +function renderAdminPage(p, data) { + appjet.requestCache.proTopNavSelection = 'admin'; + function getAdminContent() { + if (typeof(p) == 'function') { + return p(); + } else { + return renderTemplateAsString('pro/admin/'+p+'.ejs', data); + } + } + renderFramed('pro/admin/admin-template.ejs', { + getAdminContent: getAdminContent, + renderAdminLeftNav: renderAdminLeftNav, + validLicense: pne_utils.isServerLicensed(), + }); +} + +//---------------------------------------------------------------- + +function onRequest() { + var disp = new Dispatcher(); + disp.addLocations([ + [DirMatcher(license_manager_control.getPath()), forward(license_manager_control)], + [DirMatcher('/ep/admin/account-manager/'), forward(account_manager_control)], + [DirMatcher('/ep/admin/pro-config/'), forward(pro_config_control)], + [DirMatcher('/ep/admin/billing/'), forward(team_billing_control)], + ]); + + if (disp.dispatch()) { + return true; + } + + // request will be handled by this module. + pro_accounts.requireAdminAccount(); +} + +function render_main() { +// renderAdminPage('admin'); + response.redirect('/ep/admin/account-manager/') +} + +function render_pne_dashboard() { + renderAdminPage('pne-dashboard', { + renderUptime: admincontrol.renderServerUptime, + renderResponseCodes: admincontrol.renderResponseCodes, + renderPadConnections: admincontrol.renderPadConnections, + renderTransportStats: admincontrol.renderCometStats, + todayActiveUsers: licensing.getActiveUserCount(), + userQuota: licensing.getActiveUserQuota() + }); +} + +var _documentedServerOptions = [ + 'listen', + 'listenSecure', + 'transportUseWildcardSubdomains', + 'sslKeyStore', + 'sslKeyPassword', + 'etherpad.soffice', + 'etherpad.adminPass', + 'etherpad.SQL_JDBC_DRIVER', + 'etherpad.SQL_JDBC_URL', + 'etherpad.SQL_USERNAME', + 'etherpad.SQL_PASSWORD', + 'smtpServer', + 'smtpUser', + 'smtpPass', + 'configFile', + 'etherpad.licenseKey', + 'verbose' +]; + +function render_pne_config_get() { + renderAdminPage('pne-config', { + propKeys: _documentedServerOptions, + appjetConfig: appjet.config + }); +} + +function render_pne_advanced_get() { + response.redirect("/ep/admin/shell"); +} + +function render_shell_get() { + if (!(pne_utils.isPNE() || sessions.isAnEtherpadAdmin())) { + return false; + } + appjet.requestCache.proTopNavSelection = 'admin'; + renderAdminPage('pne-shell', { + oldCmd: getSession().pneAdminShellCmd, + result: getSession().pneAdminShellResult, + elapsedMs: getSession().pneAdminShellElapsed + }); + delete getSession().pneAdminShellResult; + delete getSession().pneAdminShellElapsed; +} + +function render_shell_post() { + if (!(pne_utils.isPNE() || sessions.isAnEtherpadAdmin())) { + return false; + } + var cmd = request.params.cmd; + var start = +(new Date); + getSession().pneAdminShellCmd = cmd; + getSession().pneAdminShellResult = shell.getResult(cmd); + getSession().pneAdminShellElapsed = +(new Date) - start; + response.redirect(request.path); +} + +function render_recover_padtext_get() { + function getNumRevisions(localPadId) { + return padutils.accessPadLocal(localPadId, function(pad) { + if (!pad.exists()) { return null; } + return 1+pad.getHeadRevisionNumber(); + }); + } + function getPadText(localPadId, revNum) { + return padutils.accessPadLocal(localPadId, function(pad) { + if (!pad.exists()) { return null; } + return pad.getRevisionText(revNum); + }); + } + + var localPadId = request.params.localPadId; + var revNum = request.params.revNum; + + var d = DIV({style: "font-size: .8em;"}); + + d.push(FORM({action: request.path, method: "get"}, + P({style: "margin-top: 0;"}, LABEL("Pad ID: "), + INPUT({type: "text", name: "localPadId", value: localPadId || ""}), + INPUT({type: "submit", value: "Submit"})))); + + var showPadHelp = false; + var revisions = null; + + if (!localPadId) { + showPadHelp = true; + } else { + revisions = getNumRevisions(localPadId); + if (!revisions) { + d.push(P("Pad not found: "+localPadId)); + } else { + d.push(P(B(localPadId), " has ", revisions, " revisions.")); + d.push(P("Enter a revision number (0-"+revisions+") to recover the pad text for that revision:")); + d.push(FORM({action: request.path, method: "get"}, + P(LABEL("Revision number:"), + INPUT({type: "hidden", name: "localPadId", value: localPadId}), + INPUT({type: "text", name: "revNum", value: revNum || (revisions - 1)}), + INPUT({type: "submit", value: "Submit"})))); + } + } + + if (showPadHelp) { + d.push(P({style: "font-size: 1em; color: #555;"}, + 'The pad ID is the same as the URL to the pad, without the leading "/".', + ' For example, if the pad lives at http://pad.spline.inf.fu-berlin.de/foobar,', + ' then the pad ID is "foobar" (without the quotes).')) + } + + if (revisions && revNum && (revNum < revisions)) { + var padText = getPadText(localPadId, revNum); + d.push(P(B("Pad text for ["+localPadId+"] revision #"+revNum))); + d.push(DIV({style: "font-family: monospace; border: 1px solid #ccc; background: #ffe; padding: 1em;"}, padText)); + } + + renderAdminPage(function() { return d; }); +} + + diff --git a/trunk/etherpad/src/etherpad/control/pro/admin/pro_config_control.js b/trunk/etherpad/src/etherpad/control/pro/admin/pro_config_control.js new file mode 100644 index 0000000..b03da45 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pro/admin/pro_config_control.js @@ -0,0 +1,54 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("funhtml.*"); + +import("etherpad.sessions.getSession"); +import("etherpad.control.pro.admin.pro_admin_control"); +import("etherpad.pro.pro_config"); + +function _renderTopDiv(mid, htmlId) { + var m = getSession()[mid]; + if (m) { + delete getSession()[mid]; + return DIV({id: htmlId}, m); + } else { + return ''; + } +} + +function _messageDiv() { + return _renderTopDiv('proConfigMessage', 'pro-config-message'); +} + +function render_main_get() { + pro_config.reloadConfig(); + var config = pro_config.getConfig(); + pro_admin_control.renderAdminPage('pro-config', { + config: config, + messageDiv: _messageDiv + }); +} + +function render_main_post() { + pro_config.setConfigVal('siteName', request.params.siteName); + pro_config.setConfigVal('alwaysHttps', !!request.params.alwaysHttps); + pro_config.setConfigVal('defaultPadText', request.params.defaultPadText); + getSession().proConfigMessage = "New settings applied."; + response.redirect(request.path); +} + + diff --git a/trunk/etherpad/src/etherpad/control/pro/admin/team_billing_control.js b/trunk/etherpad/src/etherpad/control/pro/admin/team_billing_control.js new file mode 100644 index 0000000..5be6a0e --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pro/admin/team_billing_control.js @@ -0,0 +1,447 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("dateutils"); +import("email.sendEmail"); +import("fastJSON"); +import("funhtml.*"); +import("jsutils.*"); +import("sqlbase.sqlcommon.inTransaction"); +import("stringutils.*"); + +import("etherpad.billing.billing"); +import("etherpad.billing.fields"); +import("etherpad.billing.team_billing"); +import("etherpad.control.pro.admin.pro_admin_control"); +import("etherpad.globals"); +import("etherpad.helpers"); +import("etherpad.pro.domains"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_utils"); +import("etherpad.sessions"); +import("etherpad.store.checkout"); +import("etherpad.utils.*"); + +import("static.js.billing_shared.{billing=>billingJS}"); + +var billingButtonName = "Confirm" + +function _cart() { + var s = sessions.getSession(); + if (! s.proBillingCart) { + s.proBillingCart = {}; + } + return s.proBillingCart; +} + +function _billingForm() { + return renderTemplateAsString('store/eepnet-checkout/billing-info.ejs', { + cart: _cart(), + billingButtonName: billingButtonName, + billingFinalPhrase: "", + helpers: helpers, + errorIfInvalid: _errorIfInvalid, + billing: billingJS, + obfuscateCC: checkout.obfuscateCC, + dollars: checkout.dollars, + countryList: fields.countryList, + usaStateList: fields.usaStateList, + getFullSuperdomainHost: pro_utils.getFullSuperdomainHost, + showCouponCode: true, + }); +} + +function _plural(num) { + return (num == 1 ? "" : "s"); +} + +function _billingSummary(domainId, subscription) { + var paymentInfo = team_billing.getRecurringBillingInfo(domainId); + if (! paymentInfo) { + return; + } + var latestInvoice = team_billing.getLatestPaidInvoice(subscription.id); + var usersSoFar = team_billing.getMaxUsers(domainId); + var costSoFar = team_billing.calculateSubscriptionCost(usersSoFar, subscription.coupon); + + var lastPaymentString = + (latestInvoice ? + "US $"+checkout.dollars(billing.centsToDollars(latestInvoice.amt))+ + " ("+latestInvoice.users+" account"+_plural(latestInvoice.users)+")"+ + ", on "+checkout.formatDate(latestInvoice.time) : + "None"); + + var coupon = false; + if (subscription.coupon) { + println("has a coupon: "+subscription.coupon); + var cval = team_billing.getCouponValue(subscription.coupon); + coupon = []; + if (cval.freeUsers) { + coupon.push(cval.freeUsers+" free user"+(cval.freeUsers == 1 ? "" : "s")); + } + if (cval.pctDiscount) { + coupon.push(cval.pctDiscount+"% savings"); + } + coupon = coupon.join(", "); + } + + return { + fullName: paymentInfo.fullname, + paymentSummary: + paymentInfo.paymentsummary + + (paymentInfo.expiration ? + ", expires "+checkout.formatExpiration(paymentInfo.expiration) : + ""), + lastPayment: lastPaymentString, + nextPayment: checkout.formatDate(subscription.paidThrough), + maxUsers: usersSoFar, + estimatedPayment: "US $"+checkout.dollars(costSoFar), + coupon: coupon + } +} + +function _statusMessage() { + if (_cart().statusMessage) { + return toHTML(P({style: "color: green;"}, _cart().statusMessage)); + } else { + return ''; + } +} + +function renderMainPage(doEdit) { + var cart = _cart(); + var domainId = domains.getRequestDomainId(); + var subscription = team_billing.getSubscriptionForCustomer(domainId); + var pendingInvoice = team_billing.getLatestPendingInvoice(domainId) + var usersSoFar = team_billing.getMaxUsers(domainId); + var costSoFar = team_billing.calculateSubscriptionCost(usersSoFar, subscription && subscription.coupon); + + checkout.guessBillingNames(cart, pro_accounts.getSessionProAccount().fullName); + if (! cart.billingReferralCode) { + if (subscription && subscription.coupon) { + cart.billingReferralCode = subscription.coupon; + } + } + + var summary = _billingSummary(domainId, subscription); + if (! summary) { + doEdit = true; + } + + pro_admin_control.renderAdminPage('manage-billing', { + billingForm: _billingForm, + doEdit: doEdit, + paymentInfo: summary, + getFullSuperdomainHost: pro_utils.getFullSuperdomainHost, + firstCharge: checkout.formatDate(subscription ? subscription.paidThrough : dateutils.nextMonth(new Date)), + billingButtonName: billingButtonName, + errorDiv: _errorDiv, + showBackButton: (summary != undefined), + statusMessage: _statusMessage, + isBehind: (subscription ? subscription.paidThrough < Date.now() - 86400*1000 : false), + amountDue: "US $"+checkout.dollars(billing.centsToDollars(pendingInvoice ? pendingInvoice.amt : costSoFar*100)), + cart: _cart() + }); + + delete _cart().errorId; + delete _cart().errorMsg; + delete _cart().statusMessage; +} + +function render_main() { + renderMainPage(false); +} + +function render_edit() { + renderMainPage(true); +} + +function _errorDiv() { + var m = _cart().errorMsg; + if (m) { + return DIV({className: 'errormsg', id: 'errormsg'}, m); + } else { + return ''; + } +} + +function _validationError(id, errorMessage) { + var cart = _cart(); + cart.errorMsg = errorMessage; + cart.errorId = {}; + if (id instanceof Array) { + id.forEach(function(k) { + cart.errorId[k] = true; + }); + } else { + cart.errorId[id] = true; + } + response.redirect('/ep/admin/billing/edit'); +} + +function _errorIfInvalid(id) { + var cart = _cart(); + if (cart.errorId && cart.errorId[id]) { + return 'error'; + } else { + return ''; + } +} + +function paypalNotifyUrl() { + return request.scheme+"://"+pro_utils.getFullSuperdomainHost()+"/ep/store/paypalnotify"; +} + +function _paymentSummary(payInfo) { + return payInfo.cardType + " ending in " + payInfo.cardNumber.substr(-4); +} + +function _expiration(payInfo) { + return payInfo.cardExpiration; +} + +function _attemptAuthorization(success_f) { + var cart = _cart(); + var domain = domains.getRequestDomainRecord(); + var domainId = domain.id; + var domainName = domain.subDomain; + var payInfo = checkout.generatePayInfo(cart); + var proAccount = pro_accounts.getSessionProAccount(); + var fullName = cart.billingFirstName+" "+cart.billingLastName; + var email = proAccount.email; + + // PCI rules require that we not store the CVV longer than necessary to complete the transaction + var savedCvv = payInfo.cardCvv; + delete payInfo.cardCvv; + checkout.writeToEncryptedLog(fastJSON.stringify({date: String(new Date()), domain: domain, payInfo: payInfo})); + payInfo.cardCvv = savedCvv; + + var result = billing.authorizePurchase(payInfo, paypalNotifyUrl()); + if (result.status == 'success') { + billing.log({type: 'new-subscription', + name: fullName, + domainId: domainId, + domainName: domainName}); + success_f(result); + } else if (result.status == 'pending') { + _validationError('', "Your authorization is pending. When it clears, your account will be activated. "+ + "You may choose to pay by different means now, or wait until your authorization clears."); + } else if (result.status == 'failure') { + var paypalResult = result.debug; + billing.log({'type': 'FATAL', value: "Direct purchase failed on paypal.", cart: cart, paypal: paypalResult}); + checkout.validateErrorFields(_validationError, "There seems to be an error in your billing information."+ + " Please verify and correct your ", + result.errorField.userErrors); + checkout.validateErrorFields(_validationError, "The bank declined your billing information. Please try a different ", + result.errorField.permanentErrors); + _validationError('', "A temporary error has prevented processing of your payment. Please try again later."); + } else { + billing.log({'type': 'FATAL', value: "Unknown error: "+result.status+" - debug: "+result.debug}); + sendEmail('support@pad.spline.inf.fu-berlin.de', 'urgent@pad.spline.inf.fu-berlin.de', 'UNKNOWN ERROR WARNING!', {}, + "Hey,\n\nThis is a billing system error. Some unknown error occurred. "+ + "This shouldn't ever happen. Probably good to let J.D. know. <grin>\n\n"+ + fastJSON.stringify(cart)); + _validationError('', "An unknown error occurred. We're looking into it!") + } +} + +function _processNewSubscription() { + _attemptAuthorization(function(result) { + var domain = domains.getRequestDomainRecord(); + var domainId = domain.id; + var domainName = domain.subDomain; + + var cart = _cart(); + var payInfo = checkout.generatePayInfo(cart); + var proAccount = pro_accounts.getSessionProAccount(); + var fullName = cart.billingFirstName+" "+cart.billingLastName; + var email = proAccount.email; + + inTransaction(function() { + + var subscriptionId = team_billing.createSubscription(domainId, cart.billingReferralCode); + + team_billing.setRecurringBillingInfo( + domainId, + fullName, + email, + _paymentSummary(payInfo), + _expiration(payInfo), + result.purchaseInfo.paypalId); + }); + + if (globals.isProduction()) { + sendEmail('sales@pad.spline.inf.fu-berlin.de', 'sales@pad.spline.inf.fu-berlin.de', "EtherPad: New paid pro account for "+fullName, {}, + "This is an automatic notification.\n\n"+fullName+" ("+email+") successfully set up "+ + "a billing profile for domain: "+domainName+"."); + } + }); +} + +function _updateExistingSubscription(subscription) { + var cart = _cart(); + + _attemptAuthorization(function(result) { + inTransaction(function() { + var cart = _cart(); + var domain = domains.getRequestDomainId(); + var payInfo = checkout.generatePayInfo(cart); + var proAccount = pro_accounts.getSessionProAccount(); + var fullName = cart.billingFirstName+" "+cart.billingLastName; + var email = proAccount.email; + + var subscriptionId = subscription.id; + + team_billing.setRecurringBillingInfo( + domain, + fullName, + email, + _paymentSummary(payInfo), + _expiration(payInfo), + result.purchaseInfo.paypalId); + }); + }); + + if (subscription.paidThrough < new Date) { + // if they're behind, do the purchase! + if (team_billing.processSubscription(subscription)) { + cart.statusMessage = "Your payment was successful, and your account is now up to date! You will receive a receipt by email." + } else { + cart.statusMessage = "Your payment failed; you will receive further instructions by email."; + } + } +} + +function _processBillingInfo() { + var cart = _cart(); + var domain = domains.getRequestDomainId(); + + var subscription = team_billing.getSubscriptionForCustomer(domain); + if (! subscription) { + _processNewSubscription(); + response.redirect('/ep/admin/billing/'); + } else { + team_billing.updateSubscriptionCouponCode(subscription.id, cart.billingReferralCode); + if (cart.billingCCNumber.length > 0) { + _updateExistingSubscription(subscription); + } + response.redirect('/ep/admin/billing') + } +} + +function _processPaypalPurchase() { + var domain = domains.getRequestDomainId(); + billing.log({type: "paypal-attempt", + domain: domain, + message: "Someone tried to use paypal to pay for on-demand."+ + " They got an error message. If this happens a lot, we should implement paypal."}) + java.lang.Thread.sleep(5000); + _validationError('billingPurchaseType', "There was an error contacting PayPal. Please try another payment type.") +} + +function _processInvoicePurchase() { + var output = [ + "Name: "+cart.billingFirstName+" "+cart.billingLastName, + "\nAddress: ", + cart.billingAddressLine1+(cart.billingAddressLine2.length > 0 ? "\n"+cart.billingAddressLine2 : ""), + cart.billingCity + ", " + (cart.billingState.length > 0 ? cart.billingState : cart.billingProvince), + cart.billingZipCode.length > 0 ? cart.billingZipCode : cart.billingPostalCode, + cart.billingCountry, + "\nEmail: ", + pro_accounts.getSessionProAccount().email + ].join("\n"); + var recipient = (globals.isProduction() ? 'sales@pad.spline.inf.fu-berlin.de' : 'jd@appjet.com'); + sendEmail( + recipient, + 'sales@pad.spline.inf.fu-berlin.de', + 'Invoice payment request - '+pro_utils.getProRequestSubdomain(), + {}, + "Hi there,\n\nA pro user tried to pay by invoice. Their information follows."+ + "\n\nThanks!\n\n"+output); + _validationError('', "Your information has been sent to our sales department; a salesperson will contact you shortly regarding your invoice request.") +} + +function render_apply() { + var cart = _cart(); + eachProperty(request.params, function(k, v) { + if (startsWith(k, "billing")) { + if (k == "billingCCNumber" && v.charAt(0) == 'X') { return; } + cart[k] = toHTML(v); + } + }); + + if (! request.params.backbutton) { + var allPaymentFields = ["billingCCNumber", "billingExpirationMonth", "billingExpirationYear", "billingCSC", "billingAddressLine1", "billingAddressLine2", "billingCity", "billingState", "billingZipCode", "billingProvince", "billingPostalCode"]; + var allBlank = true; + allPaymentFields.forEach(function(field) { if (cart[field].length > 0) { allBlank = false; }}); + if (! allBlank) { + checkout.validateBillingCart(_validationError, cart); + } + } else { + response.redirect("/ep/admin/billing/"); + } + + var couponCode = cart.billingReferralCode; + + if (couponCode.length != 0 && (couponCode.length != 8 || ! team_billing.getCouponValue(couponCode))) { + _validationError('billingReferralCode', 'Invalid referral code entered. Please verify your code and try again.'); + } + + if (cart.billingPurchaseType == 'paypal') { + _processPaypalPurchase(); + } else if (cart.billingPurchaseType == 'invoice') { + _processInvoicePurchase(); + } + + _processBillingInfo(); +} + +function handlePaypalNotify() { + // XXX: handle delayed paypal authorization +} + +function render_invoices() { + if (request.params.id) { + var purchaseId = team_billing.getSubscriptionForCustomer(domains.getRequestDomainId()).id; + var invoice = billing.getInvoice(request.params.id); + if (invoice.purchase != purchaseId) { + response.redirect(request.path); + } + + var transaction; + var adjustments = billing.getAdjustments(invoice.id); + if (adjustments.length == 1) { + transaction = billing.getTransaction(adjustments[0].transaction); + } + + pro_admin_control.renderAdminPage('single-invoice', { + formatDate: checkout.formatDate, + dollars: checkout.dollars, + centsToDollars: billing.centsToDollars, + invoice: invoice, + transaction: transaction + }); + } else { + var invoices = team_billing.getAllInvoices(domains.getRequestDomainId()); + + pro_admin_control.renderAdminPage('billing-invoices', { + invoices: invoices, + formatDate: checkout.formatDate, + dollars: checkout.dollars, + centsToDollars: billing.centsToDollars + }); + } +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/control/pro/pro_main_control.js b/trunk/etherpad/src/etherpad/control/pro/pro_main_control.js new file mode 100644 index 0000000..b4e3bc4 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pro/pro_main_control.js @@ -0,0 +1,150 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("stringutils"); +import("dispatch.{Dispatcher,DirMatcher,forward}"); +import("funhtml.*"); +import("cache_utils.syncedWithCache"); + +import("etherpad.helpers"); +import("etherpad.utils.*"); +import("etherpad.sessions.getSession"); +import("etherpad.licensing"); +import("etherpad.pne.pne_utils"); +import("etherpad.pro.pro_pad_db"); +import("etherpad.pro.domains"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_accounts.getSessionProAccount"); +import("etherpad.pro.pro_padlist"); + +import("etherpad.control.pro.account_control"); +import("etherpad.control.pro.pro_padlist_control"); +import("etherpad.control.pro.admin.pro_admin_control"); +import("etherpad.control.pro.admin.account_manager_control"); + +import("etherpad.pad.activepads"); +import("etherpad.pad.model"); + + +function onRequest() { + var disp = new Dispatcher(); + disp.addLocations([ + [DirMatcher('/ep/account/'), forward(account_control)], + [DirMatcher('/ep/admin/'), forward(pro_admin_control)], + [DirMatcher('/ep/padlist/'), forward(pro_padlist_control)], + ]); + return disp.dispatch(); +} + +function render_main() { + if (request.path == '/ep/') { + response.redirect('/'); + } + + // recent pad list + var livePads = pro_pad_db.listLiveDomainPads(); + var recentPads = pro_pad_db.listAllDomainPads(); + + var renderLivePads = function() { + return pro_padlist.renderPadList(livePads, ['title', 'connectedUsers'], 10); + } + + var renderRecentPads = function() { + return pro_padlist.renderPadList(recentPads, ['title'], 10); + }; + + var r = domains.getRequestDomainRecord(); + + renderFramed('pro/pro_home.ejs', { + isEvaluation: licensing.isEvaluation(), + account: getSessionProAccount(), + isPNE: pne_utils.isPNE(), + pneVersion: pne_utils.getVersionString(), + livePads: livePads, + recentPads: recentPads, + renderRecentPads: renderRecentPads, + renderLivePads: renderLivePads, + orgName: r.orgName + }); + return true; +} + +function render_finish_activation_get() { + if (!isActivationAllowed()) { + response.redirect('/'); + } + + var accountList = pro_accounts.listAllDomainAccounts(); + if (accountList.length > 1) { + response.redirect('/'); + } + if (accountList.length == 0) { + throw Error("accountList.length should never be 0."); + } + + var acct = accountList[0]; + var tempPass = stringutils.randomString(10); + pro_accounts.setTempPassword(acct, tempPass); + account_manager_control.sendWelcomeEmail(acct, tempPass); + + var domainId = domains.getRequestDomainId(); + + syncedWithCache('pro-activations', function(c) { + delete c[domainId]; + }); + + renderNoticeString( + DIV({style: "font-size: 16pt; border: 1px solid green; background: #eeffee; margin: 2em 4em; padding: 1em;"}, + P("Success! You will receive an email shortly with instructions."), + DIV({style: "display: none;", id: "reference"}, acct.id, ":", tempPass))); +} + +function isActivationAllowed() { + if (request.path != '/ep/finish-activation') { + return false; + } + var allowed = false; + var domainId = domains.getRequestDomainId(); + return syncedWithCache('pro-activations', function(c) { + if (c[domainId]) { + return true; + } + return false; + }); +} + +function render_payment_required_get() { + // Users get to this page when there is a problem with billing: + // possibilities: + // * they try to create a new account but they have not entered + // payment information + // + // * their credit card lapses and any pro request fails. + // + // * others? + + var message = getSession().billingProblem || "A payment is required to proceed."; + var adminList = pro_accounts.listAllDomainAdmins(); + + renderFramed("pro/pro-payment-required.ejs", { + message: message, + isAdmin: pro_accounts.isAdminSignedIn(), + adminList: adminList + }); +} + + + diff --git a/trunk/etherpad/src/etherpad/control/pro/pro_padlist_control.js b/trunk/etherpad/src/etherpad/control/pro/pro_padlist_control.js new file mode 100644 index 0000000..9a90c67 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pro/pro_padlist_control.js @@ -0,0 +1,200 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("funhtml.*"); +import("jsutils.*"); +import("stringutils"); + +import("etherpad.sessions.getSession"); +import("etherpad.utils.*"); +import("etherpad.helpers"); +import("etherpad.pad.exporthtml"); +import("etherpad.pad.padutils"); +import("etherpad.pro.pro_padmeta"); +import("etherpad.pro.pro_pad_db"); +import("etherpad.pro.domains"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_padlist"); + +jimport("java.lang.System.out.println"); + +function onRequest(name) { + if (name == "all_pads.zip") { + render_all_pads_zip_get(); + return true; + } else { + return false; + } +} + +function _getBaseUrl() { return "/ep/padlist/"; } + +function _renderPadNav() { + var d = DIV({id: "padlist-nav"}); + var ul = UL(); + var items = [ + ['allpads', 'all-pads', "All Pads"], + ['mypads', 'my-pads', "My Pads"], + ['archivedpads', 'archived-pads', "Archived Pads"] + ]; + for (var i = 0; i < items.length; i++) { + var item = items[i]; + var cn = ""; + if (request.path.split("/").slice(-1)[0] == item[1]) { + cn = "selected"; + } + ul.push(LI(A({id: "nav-"+item[1], href: _getBaseUrl()+item[1], className: cn}, item[2]))); + } + ul.push(html(helpers.clearFloats())); + d.push(ul); + d.push(FORM({id: "newpadform", method: "get", action: "/ep/pad/newpad"}, + INPUT({type: "submit", value: "New Pad"}))); + d.push(html(helpers.clearFloats())); + return d; +} + +function _renderPage(name, data) { + getSession().latestPadlistView = request.path + "?" + request.query; + var r = domains.getRequestDomainRecord(); + appjet.requestCache.proTopNavSelection = 'padlist'; + data.renderPadNav = _renderPadNav; + data.orgName = r.orgName; + data.renderNotice = function() { + var m = getSession().padlistMessage; + if (m) { + delete getSession().padlistMessage; + return DIV({className: "padlist-notice"}, m); + } else { + return ""; + } + }; + + renderFramed("pro/padlist/"+name+".ejs", data); +} + +function _renderListPage(padList, showingDesc, columns) { + _renderPage("pro-padlist", { + padList: padList, + renderPadList: function() { + return pro_padlist.renderPadList(padList, columns); + }, + renderShowingDesc: function(count) { + return DIV({id: "showing-desc"}, + "Showing "+showingDesc+" ("+count+")."); + }, + isAdmin: pro_accounts.isAdminSignedIn() + }); +} + +function render_main() { + if (!getSession().latestPadlistView) { + getSession().latestPadlistView = "/ep/padlist/all-pads"; + } + response.redirect(getSession().latestPadlistView); +} + +function render_all_pads_get() { + _renderListPage( + pro_pad_db.listAllDomainPads(), + "all pads", + ['secure', 'title', 'lastEditedDate', 'editors', 'actions']); +} + +function render_all_pads_zip_get() { + if (! pro_accounts.isAdminSignedIn()) { + response.redirect(_getBaseUrl()+"all-pads"); + } + var bytes = new java.io.ByteArrayOutputStream(); + var zos = new java.util.zip.ZipOutputStream(bytes); + + var pads = pro_pad_db.listAllDomainPads(); + pads.forEach(function(pad) { + var padHtml; + var title; + padutils.accessPadLocal(pad.localPadId, function(p) { + title = padutils.getProDisplayTitle(pad.localPadId, pad.title); + padHtml = exporthtml.getPadHTML(p); + }, "r"); + + title = title.replace(/[^\w\s]/g, "-") + ".html"; + zos.putNextEntry(new java.util.zip.ZipEntry(title)); + var padBytes = (new java.lang.String(renderTemplateAsString('pad/exporthtml.ejs', { + content: padHtml, + pre: false + }))).getBytes("UTF-8"); + + zos.write(padBytes, 0, padBytes.length); + zos.closeEntry(); + }); + zos.close(); + response.setContentType("application/zip"); + response.writeBytes(bytes.toByteArray()); +} + +function render_my_pads_get() { + _renderListPage( + pro_pad_db.listMyPads(), + "pads created by me", + ['secure', 'title', 'lastEditedDate', 'editors', 'actions']); +} + +function render_archived_pads_get() { + helpers.addClientVars({ + showingArchivedPads: true + }); + _renderListPage( + pro_pad_db.listArchivedPads(), + "archived pads", + ['secure', 'title', 'lastEditedDate', 'actions']); +} + +function render_edited_by_get() { + var editorId = request.params.editorId; + var editorName = pro_accounts.getFullNameById(editorId); + _renderListPage( + pro_pad_db.listPadsByEditor(editorId), + "pads edited by "+editorName, + ['secure', 'title', 'lastEditedDate', 'editors', 'actions']); +} + +function render_delete_post() { + var localPadId = request.params.padIdToDelete; + + pro_padmeta.accessProPadLocal(localPadId, function(propad) { + propad.markDeleted(); + getSession().padlistMessage = 'Pad "'+propad.getDisplayTitle()+'" has been deleted.'; + }); + + response.redirect(request.params.returnPath); +} + +function render_toggle_archive_post() { + var localPadId = request.params.padIdToToggleArchive; + + pro_padmeta.accessProPadLocal(localPadId, function(propad) { + if (propad.isArchived()) { + propad.unmarkArchived(); + getSession().padlistMessage = 'Pad "'+propad.getDisplayTitle()+'" has been un-archived.'; + } else { + propad.markArchived(); + getSession().padlistMessage = 'Pad "'+propad.getDisplayTitle()+'" has been archived. You can view archived pads by clicking on the "Archived" tab at the top of the pad list.'; + } + }); + + response.redirect(request.params.returnPath); +} + + diff --git a/trunk/etherpad/src/etherpad/control/pro_beta_control.js b/trunk/etherpad/src/etherpad/control/pro_beta_control.js new file mode 100644 index 0000000..ec99b43 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pro_beta_control.js @@ -0,0 +1,136 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("funhtml.*", "stringutils.*"); +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon"); +import("stringutils"); +import("email.sendEmail"); + +import("etherpad.utils.*"); +import("etherpad.log"); +import("etherpad.sessions.getSession"); + +jimport("java.lang.System.out.println"); + +function render_main_get() { + if (isValveOpen()) { + response.redirect("/ep/pro-signup/"); + } + renderFramed("beta/signup.ejs", { + errorMsg: getSession().betaSignupError + }); + delete getSession().betaSignupError; +} + +function render_signup_post() { + // record in sql: [id, email, activated=false, activationCode] + // log to disk + + var email = request.params.email; + if (!isValidEmail(email)) { + getSession().betaSignupError = "Invalid email address."; + response.redirect('/ep/beta-account/'); + } + + // does email already exist? + if (sqlobj.selectSingle('pro_beta_signups', {email: email})) { + getSession().betaSignupError = "Email already signed up."; + response.redirect('/ep/beta-account/'); + } + + sqlobj.insert('pro_beta_signups', { + email: email, + isActivated: false, + signupDate: new Date() + }); + + response.redirect('/ep/beta-account/signup-ok'); +} + +function render_signup_ok() { + renderNoticeString( + DIV({style: "font-size: 16pt; border: 1px solid green; background: #eeffee; margin: 2em 4em; padding: 1em;"}, + P("Great! We'll be in touch."), + P("In the meantime, you can ", A({href: '/ep/pad/newpad', style: 'text-decoration: underline;'}, + "create a public pad"), " right now."))); +} + +// return string if not valid, falsy otherwise. +function isValidCode(code) { + if (isValveOpen()) { + return undefined; + } + + function wr(m) { + return DIV(P(m), P("You can sign up for the beta ", + A({href: "/ep/beta-account/"}, "here"))); + } + + if (!code) { + return wr("Invalid activation code."); + } + var record = sqlobj.selectSingle('pro_beta_signups', { activationCode: code }); + if (!record) { + return wr("Invalid activation code."); + } + if (record.isActivated) { + return wr("That activation code has already been used."); + } + return undefined; +} + +function isValveOpen() { + if (appjet.cache.proBetaValveIsOpen === undefined) { + appjet.cache.proBetaValveIsOpen = true; + } + return appjet.cache.proBetaValveIsOpen; +} + +function toggleValve() { + appjet.cache.proBetaValveIsOpen = !appjet.cache.proBetaValveIsOpen; +} + +function sendInvite(recordId) { + var record = sqlobj.selectSingle('pro_beta_signups', {id: recordId}); + if (record.activationCode) { + getSession().betaAdminMessage = "Already active"; + return; + } + + // create activation code + var code = stringutils.randomString(10); + sqlcommon.inTransaction(function() { + sqlobj.update('pro_beta_signups', {id: recordId}, {activationCode: code}); + var body = renderTemplateAsString('email/pro_beta_invite.ejs', { + toAddr: record.email, + signupAgo: timeAgo(record.signupDate), + signupCode: code, + activationUrl: "http://"+httpHost(request.host)+"/ep/pro-signup/?sc="+code + }); + sendEmail(record.email, "EtherPad <support@pad.spline.inf.fu-berlin.de>", + "Your EtherPad Professional Beta Account", {}, body); + }); + + getSession().betaAdminMessage = "Invite sent."; +} + +function notifyActivated(code) { + println("updating: "+code); + sqlobj.update('pro_beta_signups', {activationCode: code}, + {isActivated: true, activationDate: new Date()}); +} + diff --git a/trunk/etherpad/src/etherpad/control/pro_signup_control.js b/trunk/etherpad/src/etherpad/control/pro_signup_control.js new file mode 100644 index 0000000..6bf7cc3 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/pro_signup_control.js @@ -0,0 +1,173 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("jsutils.*"); +import("cache_utils.syncedWithCache"); +import("funhtml.*"); +import("stringutils"); +import("stringutils.*"); +import("sqlbase.sqlcommon"); + +import("etherpad.sessions.getSession"); +import("etherpad.utils.*"); + +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.domains"); + +import("etherpad.control.pro_beta_control"); +import("etherpad.control.pro.admin.account_manager_control"); + +import("etherpad.helpers"); + +function onRequest() { + if (!getSession().ods) { + getSession().ods = {}; + } + if (request.method == "POST") { + // add params to cart + eachProperty(request.params, function(k,v) { + getSession().ods[k] = stringutils.toHTML(v); + }); + } +} + +function _errorDiv() { + var m = getSession().errorMessage; + if (m) { + delete getSession().errorMessage; + return DIV({className: 'err'}, m); + } + return ""; +} + +function _input(id, type) { + return INPUT({type: type ? type : 'text', name: id, id: id, + value: getSession().ods[id] || ""}); +} + +function _inf(id, label, type) { + return DIV( + DIV({style: "width: 100px; text-align: right; float: left; padding-top: 3px;"}, label, ": "), + DIV({style: "text-align: left; float: left;"}, + _input(id, type)), + DIV({style: "height: 6px; clear: both;"}, " ")); +} + +function render_main_get() { + // observe activation code + if (request.params.sc) { + getSession().betaActivationCode = request.params.sc; + response.redirect(request.path); + } + + // validate activation code + var activationCode = getSession().betaActivationCode; + var err = pro_beta_control.isValidCode(activationCode); + if (err) { + renderNoticeString(DIV({style: "border: 1px solid red; background: #fdd; font-weight: bold; padding: 1em;"}, + err)); + response.stop(); + } + + // serve activation page + renderFramed('main/pro_signup_body.ejs', { + errorDiv: _errorDiv, + input: _input, + inf: _inf + }); +} + +function _err(m) { + if (m) { + getSession().errorMessage = m; + response.redirect(request.path); + } +} + +function render_main_post() { + var subdomain = trim(String(request.params.subdomain).toLowerCase()); + var fullName = request.params.fullName; + var email = trim(request.params.email); + + // validate activation code + var activationCode = getSession().betaActivationCode; + var err = pro_beta_control.isValidCode(activationCode); + if (err) { + resonse.write(err); + } + + /* + var password = request.params.password; + var passwordConfirm = request.params.passwordConfirm; + */ + var orgName = subdomain; + + //---- basic validation ---- + if (!/^\w[\w\d\-]*$/.test(subdomain)) { + _err("Invalid domain: "+subdomain); + } + if (subdomain.length < 2) { + _err("Subdomain must be at least 2 characters."); + } + if (subdomain.length > 60) { + _err("Subdomain must be <= 60 characters."); + } + +/* + if (password != passwordConfirm) { + _err("Passwords do not match."); + } + */ + + _err(pro_accounts.validateFullName(fullName)); + _err(pro_accounts.validateEmail(email)); + + if (!(email.match(/[Ff][Uu]-[Bb][Ee][Rr][Ll][Ii][Nn].[Dd][Ee]$/))) { _err("Please use your *.fu-berlin.de email address."); } +// _err(pro_accounts.validatePassword(password)); + + //---- database validation ---- + + if (domains.doesSubdomainExist(subdomain)) { + _err("The domain "+subdomain+" is already in use."); + } + + //---- looks good. create records! ---- + + // TODO: log a bunch of stuff, and request IP address, etc. + + var ok = false; + sqlcommon.inTransaction(function() { + var tempPass = stringutils.randomString(10); + // TODO: move validation code into domains.createNewSubdomain... + var domainId = domains.createNewSubdomain(subdomain, orgName); + var accountId = pro_accounts.createNewAccount(domainId, fullName, email, tempPass, true); + // send welcome email + syncedWithCache('pro-activations', function(c) { + c[domainId] = true; + }); + ok = true; + if (activationCode) { + pro_beta_control.notifyActivated(activationCode); + } + }); + + if (ok) { + response.redirect('http://'+subdomain+"."+request.host+'/ep/finish-activation'); + } else { + response.write("There was an error processing your request."); + } +} + diff --git a/trunk/etherpad/src/etherpad/control/scriptcontrol.js b/trunk/etherpad/src/etherpad/control/scriptcontrol.js new file mode 100644 index 0000000..16efc60 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/scriptcontrol.js @@ -0,0 +1,75 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.pad.dbwriter"); +import("etherpad.utils.*"); +import("etherpad.globals.*"); + +function onRequest() { + if (!isProduction()) { + return; + } + if (request.params.auth != 'f83kg840d12jk') { + response.forbid(); + } +} + +function render_setdbwritable() { + var dbwritable = (String(request.params.value).toLowerCase() != 'false'); // default to true + + dbwriter.setWritableState({constant: dbwritable}); + + response.write("OK, set to "+dbwritable); +} + +function render_getdbwritable() { + var state = dbwriter.getWritableState(); + + response.write(String(dbwriter.getWritableStateDescription(state))); +} + +function render_pausedbwriter() { + var seconds = request.params.seconds; + var seconds = Number(seconds || 0); + if (isNaN(seconds)) seconds = 0; + + var finishTime = (+new Date())+(1000*seconds); + dbwriter.setWritableState({trueAfter: finishTime}); + + response.write("Paused dbwriter for "+seconds+" seconds."); +} + +function render_fake_pne_on() { + if (isProduction()) { + response.write("has no effect in production."); + } else { + appjet.cache.fakePNE = true; + response.write("OK"); + } +} + +function render_fake_pne_off() { + if (isProduction()) { + response.write("has no effect in production."); + } else { + appjet.cache.fakePNE = false; + response.write("OK"); + } +} + + + + diff --git a/trunk/etherpad/src/etherpad/control/static_control.js b/trunk/etherpad/src/etherpad/control/static_control.js new file mode 100644 index 0000000..5c087b6 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/static_control.js @@ -0,0 +1,65 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("faststatic"); +import("dispatch.{Dispatcher,PrefixMatcher,forward}"); + +import("etherpad.utils.*"); +import("etherpad.globals.*"); + +function onRequest() { + var staticBase = '/static'; + + var opts = {cache: isProduction()}; + + var serveFavicon = faststatic.singleFileServer(staticBase + '/favicon.ico', opts); + var serveCrossDomain = faststatic.singleFileServer(staticBase + '/crossdomain.xml', opts); + var serveStaticDir = faststatic.directoryServer(staticBase, opts); + var serveCompressed = faststatic.compressedFileServer(opts); + var serveJs = faststatic.directoryServer(staticBase+'/js/', opts); + var serveCss = faststatic.directoryServer(staticBase+'/css/', opts); + var serveSwf = faststatic.directoryServer(staticBase+'/swf/', opts); + var serveHtml = faststatic.directoryServer(staticBase+'/html/', opts); + var serveZip = faststatic.directoryServer(staticBase+'/zip/', opts); + + var disp = new Dispatcher(); + + disp.addLocations([ + ['/favicon.ico', serveFavicon], + ['/robots.txt', serveRobotsTxt], + ['/crossdomain.xml', serveCrossDomain], + [PrefixMatcher('/static/html/'), serveHtml], + [PrefixMatcher('/static/js/'), serveJs], + [PrefixMatcher('/static/css/'), serveCss], + [PrefixMatcher('/static/swf/'), serveSwf], + [PrefixMatcher('/static/zip/'), serveZip], + [PrefixMatcher('/static/compressed/'), serveCompressed], + [PrefixMatcher('/static/'), serveStaticDir] + ]); + + return disp.dispatch(); +} + +function serveRobotsTxt(name) { + response.neverCache(); + response.setContentType('text/plain'); + response.write('User-agent: *\n'); + if (!isProduction()) { + response.write('Disallow: /\n'); + } + response.stop(); + return true; +} diff --git a/trunk/etherpad/src/etherpad/control/statscontrol.js b/trunk/etherpad/src/etherpad/control/statscontrol.js new file mode 100644 index 0000000..3659107 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/statscontrol.js @@ -0,0 +1,1214 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("fastJSON"); +import("netutils"); +import("funhtml.*"); +import("stringutils.{html,sprintf,startsWith,md5}"); +import("jsutils.*"); +import("sqlbase.sqlbase"); +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); +import("dispatch.{Dispatcher,PrefixMatcher,DirMatcher,forward}"); + +import("etherpad.globals.*"); +import("etherpad.utils.*"); +import("etherpad.sessions.getSession"); +import("etherpad.sessions"); +import("etherpad.statistics.statistics"); +import("etherpad.log"); +import("etherpad.usage_stats.usage_stats"); +import("etherpad.helpers"); + +//---------------------------------------------------------------- +// Usagestats +//---------------------------------------------------------------- + +var _defaultPrefs = { + topNCount: 5, + granularity: 1440 +} + +function onRequest() { + keys(_defaultPrefs).forEach(function(prefName) { + if (request.params[prefName]) { + _prefs()[prefName] = request.params[prefName]; + } + }); + if (request.isPost) { + response.redirect( + request.path+ + (request.query ? "?"+request.query : "")+ + (request.params.fragment ? "#"+request.params.fragment : "")); + } +} + +function _prefs() { + if (! sessions.getSession().statsPrefs) { + sessions.getSession().statsPrefs = {} + } + return sessions.getSession().statsPrefs; +} + +function _pref(pname) { + return _prefs()[pname] || _defaultPrefs[pname]; +} + +function _topN() { + return _pref('topNCount'); +} +function _showLiveStats() { + return _timescale() < 1440; + // return _pref('granularity') == 'live'; +} +function _showHistStats() { + return _timescale() >= 1440 + // return _pref('showLiveOrHistorical') == 'hist'; +} +function _timescale() { + return Number(_pref('granularity')) || 1; +} + +// types: +// compare - compare one or more single-value stats +// top - show top values over time +// histogram - show histogram over time + +var statDisplays = { + users: [ + { name: "visitors", + description: "User visits, total over a %t period", + type: "compare", + stats: [ {stat: "site_pageviews", + description: "Page views", + color: "FFA928" }, + {stat: "site_unique_ips", + description: "Unique IPs", + color: "00FF00" } ] }, + + // free pad usage + { name: "free pad usage, 1 day", + description: "Free pad.spline.inf.fu-berlin.de users, total over a %t period", + type: "compare", + stats: [ {stat: "active_user_ids", + description: "All users", + color: "FFA928" }, + {stat: "users_1day_returning_7days", + description: "Users returning after 7 days", + color: "00FF00"}, + {stat: "users_1day_returning_30days", + description: "Users returning after 30 days", + color: "FF0000"} ] }, + { name: "free pad usage, 7 day", + description: "Free pad.spline.inf.fu-berlin.de users over the last 7 days", + type: "compare", + options: { hideLive: true, latestUseHistorical: true}, + stats: [ {stat: "active_user_ids_7days", + description: "All users", + color: "FFA928" }, + {stat: "users_7day_returning_7days", + description: "Users returning after 7 days", + color: "00FF00"}, + {stat: "users_7day_returning_30days", + description: "Users returning after 30 days", + color: "FF0000"} ] }, + { name: "free pad usage, 30 day", + description: "Free pad.spline.inf.fu-berlin.de users over the last 30 days", + type: "compare", + options: { hideLive: true, latestUseHistorical: true}, + stats: [ {stat: "active_user_ids_30days", + description: "All users", + color: "FFA928" }, + {stat: "users_30day_returning_7days", + description: "Users returning after 7 days", + color: "00FF00"}, + {stat: "users_30day_returning_30days", + description: "Users returning after 30 days", + color: "FF0000"} ] }, + + // pro pad usage + { name: "active pro accounts, 1 day", + description: "Active pro accounts, total over a %t period", + type: "compare", + stats: [ {stat: "active_pro_accounts", + description: "All accounts", + color: "FFA928" }, + {stat: "pro_accounts_1day_returning_7days", + description: "Accounts older than 7 days", + color: "00FF00"}, + {stat: "pro_accounts_1day_returning_30days", + description: "Accounts older than 30 days", + color: "FF0000"} ] }, + { name: "active pro accounts, 7 day", + description: "Active pro accounts over the last 7 days", + type: "compare", + options: { hideLive: true, latestUseHistorical: true}, + stats: [ {stat: "active_pro_accounts_7days", + description: "All accounts", + color: "FFA928" }, + {stat: "pro_accounts_7day_returning_7days", + description: "Accounts older than 7 days", + color: "00FF00"}, + {stat: "pro_accounts_7day_returning_30days", + description: "Accounts older than 30 days", + color: "FF0000"} ] }, + { name: "active pro accounts, 30 day", + description: "Active pro accounts over the last 30 days", + type: "compare", + options: { hideLive: true, latestUseHistorical: true}, + stats: [ {stat: "active_pro_accounts_30days", + description: "All accounts", + color: "FFA928" }, + {stat: "pro_accounts_30day_returning_7days", + description: "Accounts older than 7 days", + color: "00FF00"}, + {stat: "pro_accounts_30day_returning_30days", + description: "Accounts older than 30 days", + color: "FF0000"} ] }, + + // other stats + { name: "pad connections", + description: "Number of active comet connections, mean over a %t period", + type: "top", + options: {showOthers: false}, + stats: ["streaming_connections"] }, + { name: "referers", + description: "Referers, number of hits over a %t period", + type: "top", + options: {showOthers: false}, + stats: ["top_referers"] }, + ], + product: [ + { name: "pads", + description: "Newly-created and active pads, total over a %t period", + type: "compare", + stats: [ {stat: "active_pads", + description: "Active pads", + color: "FFA928" }, + {stat: "new_pads", + description: "New pads", + color: "FF0000" }] }, + { name: "chats", + description: "Chat messages and active chatters, total over a %t period", + type: "compare", + stats: [ {stat: "chat_messages", + description: "Messages", + color: "FFA928" }, + {stat: "active_chatters", + description: "Chatters", + color: "FF0000" }] }, + { name: "import/export", + description: "Imports and Exports, total over a %t period", + type: "compare", + stats: [ {stat: {f: '+', args: ["imports_exports_counts:export", "imports_exports_counts:import"]}, + description: "Total", + color: "FFA928" }, + {stat: "imports_exports_counts:export", + description: "Exports", + color: "FF0000"}, + {stat: "imports_exports_counts:import", + description: "Imports", + color: "00FF00"}] }, + { name: "revenue", + description: "Revenue, total over a %t period", + type: "compare", + stats: [ {stat: "revenue", + description: "Revenue", + color: "FFA928"}] } + ], + performance: [ + { name: "dynamic page latencies", + description: "Slowest dynamic pages: mean load time in milliseconds over a %t period", + type: "top", + options: {showOthers: false}, + stats: ["execution_latencies"] }, + { name: "pad startup latencies", + description: "Pad startup times: percent load time in milliseconds over a %t period", + type: "histogram", + stats: ["pad_startup_times"] }, + { name: "stream post latencies", + description: "Comet post latencies, percentiles in milliseconds over a %t period", + type: "histogram", + stats: ["streaming_latencies"] }, + ], + health: [ + { name: "disconnect causes", + description: "Causes of disconnects, total over a %t period", + type: "top", + stats: ["disconnect_causes"] }, + { name: "paths with 404s", + description: "'Not found' responses, by path, number served over a %t period", + type: "top", + stats: ["paths_404"] }, + { name: "exceptions", + description: "Total number of server exceptions over a %t period", + type: "compare", + stats: [ {stat: "exceptions", + description: "Exceptions", + color: "FF1928" } ] }, + { name: "paths with 500s", + type: "top", + description: "'500' responses, by path, number served over a %t period", + type: "top", + stats: ["paths_500"] }, + { name: "paths with exceptions", + description: "responses with exceptions, by path, number served over a %t period", + type: "top", + stats: ["paths_exception"] }, + { name: "disconnects with client-side errors", + description: "user disconnects with an error on the client side, number over a %t period", + type: "compare", + stats: [ { stat: "disconnects_with_clientside_errors", + description: "Disconnects with errors", + color: "FFA928" } ] }, + { name: "unnecessary disconnects", + description: "disconnects that were avoidable, number over a %t period", + type: "compare", + stats: [ { stat: "streaming_disconnects:disconnected_userids", + description: "Number of unique users disconnected", + color: "FFA928" }, + { stat: "streaming_disconnects:total_disconnects", + description: "Total number of disconnects", + color: "FF0000" } ] }, + ] +} + +function getUsedStats(statStructure) { + var stats = {}; + function getStructureValues(statStructure) { + if (typeof(statStructure) == 'string') { + stats[statStructure] = true; + } else { + statStructure.args.forEach(getStructureValues); + } + } + getStructureValues(statStructure); + return keys(stats); +} + +function getStatData(statStructure, values_f) { + function getStructureValues(statStructure) { + if (typeof(statStructure) == 'string') { + return values_f(statStructure); + } else if (typeof(statStructure) == 'number') { + return statStructure; + } else { + var args = statStructure.args.map(getStructureValues); + return { + f: statStructure.f, + args: args + } + } + } + + var mappedStructure = getStructureValues(statStructure); + + function evalStructure(statStructure) { + if ((typeof(statStructure) == 'number') || (statStructure instanceof Array)) { + return statStructure; + } else { + var merge_f = statStructure.f; + if (typeof(merge_f) == 'string') { + switch (merge_f) { + case '+': + merge_f = function() { + var sum = 0; + for (var i = 0; i < arguments.length; ++i) { + sum += arguments[i]; + } + return sum; + } + break; + case '*': + merge_f = function() { + var product = 0; + for (var i = 0; i < arguments.length; ++i) { + product *= arguments[i]; + } + return product; + } + break; + case '/': + merge_f = function(a, b) { return a / b; } + break; + case '-': + merge_f = function(a, b) { return a - b; } + break; + } + } + var evaluatedArguments = statStructure.args.map(evalStructure); + var length = -1; + evaluatedArguments.forEach(function(arg) { + if (typeof(arg) == 'object' && (arg instanceof Array)) { + length = arg.length; + } + }); + evaluatedArguments = evaluatedArguments.map(function(arg) { + if (typeof(arg) == 'number') { + var newArg = new Array(length); + for (var i = 0; i < newArg.length; ++i) { + newArg[i] = arg; + } + return newArg + } else { + return arg; + } + }); + return mergeArrays.apply(this, [merge_f].concat(evaluatedArguments)); + } + } + return evalStructure(mappedStructure); +} + +var googleChartSimpleEncoding = "ABCDEFGHIJLKMNOPQRSTUVQXYZabcdefghijklmnopqrstuvwxyz0123456789-."; +function _enc(value) { + return googleChartSimpleEncoding[Math.floor(value/64)] + googleChartSimpleEncoding[value%64]; +} + +function drawSparkline(dataSets, labels, colors, minutes) { + var max = 1; + var maxLength = 0; + dataSets.forEach(function(dataSet, i) { + if (dataSet.length > maxLength) { + maxLength = dataSet.length; + } + dataSet.forEach(function(point) { + if (point > max) { + max = point; + } + }); + }); + var data = dataSets.map(function(dataSet) { + var chars = dataSet.map(function(x) { + if (x !== undefined) { + return _enc(Math.round(x/max*4095)); + } else { + return "__"; + } + }).join(""); + while (chars.length < maxLength*2) { + chars = "__"+chars; + } + return chars; + }).join(","); + var timeLabels; + if (minutes < 60*24) { + timeLabels = [4,3,2,1,0].map(function(t) { + var minutesPerTick = minutes/4; + var d = new Date(Date.now() - minutesPerTick*60000*t); + return (d.getHours()%12 || 12)+":"+(d.getMinutes() < 10 ? "0" : "")+d.getMinutes()+(d.getHours() < 12 ? "am":"pm"); + }).join("|"); + } else { + timeLabels = [4,3,2,1,0].map(function(t) { + var daysPerTick = (minutes/(60*24))/4; + var d = new Date(Date.now() - t*daysPerTick*24*60*60*1000); + return (d.getMonth()+1)+"/"+d.getDate(); + }).join("|"); + } + var pointLabels = dataSets.map(function(dataSet, i) { + return ["t"+dataSet[dataSet.length-1],colors[i],i,maxLength-1,12,0].join(","); + }).join("|"); + labels = labels.map(function(label) { + return encodeURIComponent((label.length > 73) ? label.slice(0, 70) + "..." : label); + }); + var step = Math.round(max/10); + step = Math.round(step/Math.pow(10, String(step).length-1))*Math.pow(10, String(step).length-1); + var srcUrl = + "http://chart.apis.google.com/chart?chs=600x300&cht=lc&chd=e:"+data+ + "&chxt=y,x&chco="+colors.join(",")+"&chxr=0,0,"+max+","+step+"&chxl=1:|"+timeLabels+ + "&chdl="+labels.join("|")+"&chdlp=b&chm="+pointLabels; + return toHTML(IMG({src: srcUrl})); +} + +var liveDataNumSamples = 20; + +function extractStatValuesFunction(nameToValues_f) { + return function(statName) { + var value; + if (statName.indexOf(":") >= 0) { + [statName, value] = statName.split(":"); + } + var h = nameToValues_f(statName); + if (value) { + h = h.map(function(topValues) { + if (! topValues) { return; } + var tv = topValues.topValues; + for (var i = 0; i < tv.length; ++i) { + if (tv[i].value == value) { + return tv[i].count; + } + } + return 0; + }); + } + return h; + } +} + +function sparkline_compare(history_f, minutesPerSample, stat) { + var histories = stat.stats.map(function(stat) { + var samples = getStatData(stat.stat, extractStatValuesFunction(history_f)); + return [samples, stat.description, stat.color]; + }); + return drawSparkline(histories.map(function(history) { return history[0] }), + histories.map(function(history) { return history[1] }), + histories.map(function(history) { return history[2] }), + minutesPerSample*histories[0][0].length); +} + +function sparkline_top(history_f, minutesPerSample, stat) { + var showOthers = ! stat.options || stat.options.showOthers != false; + var history = stat.stats.map(history_f)[0]; + + if (history.length == 0) { + return "<b>no data</b>"; + } + var topRecents = {}; + var topRecents_arr = []; + history.forEach(function(tv) { + if (! tv) { return; } + if (tv.topValues.length > 0) { + topRecents_arr = tv.topValues.map(function(x) { return x.value; }); + } + }); + + if (topRecents_arr.length == 0) { + return "<b>no data</b>"; + } + topRecents_arr = topRecents_arr.slice(0, _topN()); + topRecents_arr.forEach(function(value, i) { + topRecents[value] = i; + }); + + if (showOthers) { + topRecents_arr.push("Other"); + } + var max = 1; + var values = topRecents_arr.map(function() { return history.map(function() { return 0 }); }); + + history.forEach(function(tv, i) { + if (! tv) { return; } + tv.topValues.forEach(function(entry) { + if (entry.count > max) { + max = entry.count; + } + if (entry.value in topRecents) { + values[topRecents[entry.value]][i] = entry.count; + } else if (showOthers) { + values[values.length-1][i] += entry.count; + } + }); + }); + return drawSparkline( + values, + topRecents_arr, + ["FF0000", "00FF00", "0000FF", "FF00FF", "00FFFF"].slice(0, topRecents_arr.length-1).concat("FFA928"), + minutesPerSample*history.length); +} + +function sparkline_histogram(history_f, minutesPerSample, stat) { + var history = stat.stats.map(history_f)[0]; + + if (history.length == 0) { + return "<b>no data</b>"; + } + var percentiles = [50, 90, 95, 99]; + var data = percentiles.map(function() { return []; }) + history.forEach(function(hist) { + percentiles.forEach(function(pct, i) { + data[i].push((hist ? hist[""+pct] : undefined)); + }); + }); + return drawSparkline( + data, + percentiles.map(function(pct) { return ""+pct+"%"; }), + ["FF0000","FF00FF","FFA928","00FF00"].reverse(), + minutesPerSample*history.length); +} + +function liveHistoryFunction(minutesPerSample) { + return function(statName) { + return statistics.liveSnapshot(statName).history(minutesPerSample, liveDataNumSamples); + } +} + +function _listStats(statName, count) { + var options = { orderBy: '-timestamp,id' }; + if (count !== undefined) { + options.limit = count; + } + return sqlobj.selectMulti('statistics', {name: statName}, options); +} + +function ancientHistoryFunction(time) { + return function(statName) { + var seenDates = {}; + var samples = _listStats(statName); + + samples = samples.reverse().map(function(json) { + if (seenDates[""+json.timestamp]) { return; } + seenDates[""+json.timestamp] = true; + return {timestamp: json.timestamp, json: json.value}; + }).filter(function(x) { return x !== undefined }); + + samples = samples.reverse().slice(0, Math.round(time/(24*60))); + var samplesWithEmptyValues = []; + for (var i = 0; i < samples.length-1; ++i) { + var current = samples[i]; + var next = samples[i+1]; + samplesWithEmptyValues.push(current.json); + for (var j = current.timestamp+86400*1000; j < next.timestamp; j += 86400*1000) { + samplesWithEmptyValues.push(undefined); + } + } + if (samples.length > 0) { + samplesWithEmptyValues.push(samples[samples.length-1].json); + } + samplesWithEmptyValues = samplesWithEmptyValues.map(function(json) { + if (! json) { return; } + var obj = fastJSON.parse(json); + if (keys(obj).length == 1 && 'value' in obj) { + obj = obj.value; + } + return obj; + }); + + return samplesWithEmptyValues.reverse(); + } +} + +function sparkline(history_f, minutesPerSample, stat) { + if (this["sparkline_"+stat.type]) { + return this["sparkline_"+stat.type](history_f, minutesPerSample, stat); + } else { + return "<b>No sparkline handler!</b>"; + } +} + +function liveLatestFunction(minutesPerSample) { + return function(statName) { + return [statistics.liveSnapshot(statName).latest(minutesPerSample)]; + } +} + +function liveTotal(statName) { + return [statistics.liveSnapshot(statName).total]; +} + +function historyLatest(statName) { + return _listStats(statName, 1).map(function(x) { + var value = fastJSON.parse(x.value); + if (keys(value).length == 1 && 'value' in value) { + value = value.value; + } + return value; + }); +} + +function latest_compare(latest_f, stat) { + return stat.stats.map(function(stat) { + var sample = getStatData(stat.stat, extractStatValuesFunction(latest_f))[0]; + return { value: sample, description: stat.description }; + }); +} + +function latest_top(latest_f, stat) { + var showOthers = ! stat.options || stat.options.showOthers != false; + + var sample = stat.stats.map(latest_f)[0][0]; + if (! sample) { + return []; + } + var total = sample.count; + + var values = sample.topValues.slice(0, _topN()).map(function(v) { + total -= v.count; + return { value: v.count, description: v.value }; + }); + if (showOthers) { + values.push({value: total, description: "Other"}); + } + return values; +} + +function latest_histogram(latest_f, stat) { + var sample = stat.stats.map(latest_f)[0][0]; + + if (! sample) { + return "<b>no data</b>"; + } + + var percentiles = [0, 1, 5, 10, 25, 50, 75, 90, 95, 99, 100].filter(function(pct) { return ((""+pct) in sample) }); + + var xpos = percentiles.map(function(x, i) { return sample[x] }); + var xMax = 0; + var xMin = 1e12; + xpos.forEach(function(x) { xMax = (x > xMax ? x : xMax); xMin = (x < xMin ? x : xMin); }); + xposNormalized = xpos.map(function(x) { return Math.round((x-xMin)/(xMax-xMin || 1)*100); }); + + var ypos = percentiles.slice(1).map(function(y, i) { return (y-percentiles[i])/(xpos[i+1] || 1); }); + var yMax = 0; + ypos.forEach(function(y) { yMax = (y > yMax ? y : yMax); }); + yposNormalized = ypos.map(function(y) { return Math.round(y/yMax*100); }); + + // var proposedLabels = mergeArrays(function(x, y) { return {pos: x, label: y}; }, xposNormalized, xpos); + // var keepLabels = [{pos: 0, label: 0}]; + // proposedLabels.forEach(function(label) { + // if (label.pos - keepLabels[keepLabels.length-1].pos > 10) { + // keepLabels.push(label); + // } + // }); + // + // var labelPos = keepLabels.map(function(x) { return x.pos }); + // var labels = keepLabels.map(function(x) { return x.label }); + + return toHTML(IMG({src: + "http://chart.apis.google.com/chart?chs=340x100&cht=lxy&chd=t:"+xposNormalized.join(",")+"|0,"+yposNormalized.join(",")+ + "&chxt=x&chxr=0,"+xMin+","+xMax+","+Math.floor((xMax-xMin)/5) // "l=0:|"+labels.join("|")+"&chxp=0,"+labelPos.join(",") + })); +} + +function latest(latest_f, stat) { + if (this["latest_"+stat.type]) { + return this["latest_"+stat.type](latest_f, stat); + } else { + return "<b>No latest handler!</b>"; + } +} + +function dropdown(name, options, selected) { + var select; + if (typeof(name) == 'string') { + select = SELECT({name: name}); + } else { + select = SELECT(name); + } + + function addOption(value, content) { + var opt = OPTION({value: value}, content || value); + if (value == selected) { + opt.attribs.selected = "selected"; + } + select.push(opt); + } + + if (options instanceof Array) { + options.forEach(f_limitArgs(this, addOption, 1)); + } else { + eachProperty(options, addOption); + } + return select; +} + +function render_main() { + var categoriesToStats = {}; + + eachProperty(statDisplays, function(catName, statArray) { + categoriesToStats[catName] = statArray.map(_renderableStat); + }); + + renderHtml('statistics/stat_page.ejs', + {eachProperty: eachProperty, + statCategoryNames: keys(categoriesToStats), + categoriesToStats: categoriesToStats, + optionsForm: _optionsForm() }); +} + +function _optionsForm() { + return FORM({id: "statprefs", method: "POST"}, "Show data with granularity: ", + // dropdown({name: 'showLiveOrHistorical', onchange: 'formChanged();'}, + // {live: 'live', hist: 'historical'}, + // _pref('showLiveOrHistorical')), + // (_showLiveStats() ? + // SPAN(" with granularity ", + dropdown({name: 'granularity', onchange: 'formChanged();'}, + {"1": '1 minute', "5": '5 minutes', "60": '1 hour', "1440": '1 day'}, + _pref('granularity')), // ), + // : ""), + " top N:", + INPUT({type: "text", name: "topNCount", value: _topN()}), + INPUT({type: "submit", name: "Set", value: "set N"}), + INPUT({type: "hidden", name: "fragment", id: "fragment", value: "health"})); +} + +// function render_main() { +// var body = BODY(); +// +// var cat = request.params.cat; +// if (!cat) { +// cat = 'health'; +// } +// +// body.push(A({id: "backtoadmin", href: "/ep/admin/"}, html("«"), " back to admin")); +// body.push(_renderTopnav(cat)); +// +// body.push(form); +// +// if (request.params.stat) { +// body.push(A({className: "viewall", +// href: qpath({stat: null})}, html("«"), " view all")); +// } +// +// var statNames = statDisplays[cat]; +// statNames.forEach(function(sn) { +// if (!request.params.stat || (request.params.stat == sn)) { +// body.push(_renderableStat(sn)); +// } +// }); +// +// helpers.includeCss('admin/admin-stats.css'); +// response.write(HTML(HEAD(html(helpers.cssIncludes())), body)); +// } + +function _getLatest(stat) { + var minutesPerSample = _timescale(); + + if (_showLiveStats()) { + return latest(liveLatestFunction(minutesPerSample), stat); + } else { + return latest(liveTotal, stat); + } +} + +function _getGraph(stat) { + var minutesPerSample = _timescale(); + + if (_showLiveStats()) { + return html(sparkline(liveHistoryFunction(minutesPerSample), minutesPerSample, stat)); + } else { + return html(sparkline(ancientHistoryFunction(60*24*60), 24*60, stat)); + } +} + +function _getDataLinks(stat) { + if (_showLiveStats()) { + return; + } + + function listToLinks(list) { + var links = []; //SPAN({className: "datalink"}, "(data for "); + list.forEach(function(statName) { + links.push(toHTML(A({href: "/ep/admin/usagestats/data?statName="+statName}, statName))); + }); +// links.push(")"); + return links; + } + + switch (stat.type) { + case 'compare': + var stats = []; + stat.stats.map(function(stat) { return getUsedStats(stat.stat); }).forEach(function(list) { + stats = stats.concat(list); + }); + return listToLinks(stats); + case 'top': + return listToLinks(stat.stats); + case 'histogram': + return listToLinks(stat.stats); + } +} + +function _renderableStat(stat) { + var minutesPerSample = _timescale(); + + var period = (_showLiveStats() ? minutesPerSample : 24*60); + + if (period < 24*60 && stat.hideLive) { + return; + } + + if (period < 60) { + period = ""+period+"-minute"; + } else if (period < 24*60) { + period = ""+period/(60)+"-hour"; + } else if (period >= 24*60) { + period = ""+period/(24*60)+"-day"; + } + var graph = _getGraph(stat); + var id = stat.name.replace(/[^a-zA-Z0-9]/g, ""); + + var displayName = stat.description.replace("%t", period); + var latest = _getLatest(stat); + var dataLinks = _getDataLinks(stat); + + return { + id: id, + specialState: "", + displayName: displayName, + name: stat.name, + graph: graph, + latest: latest, + dataLinks: dataLinks + } +} + +function render_data() { + var sn = request.params.statName; + var t = TABLE({border: 1, cellpadding: 2, style: "font-family: monospace;"}); + _listStats(sn).forEach(function(s) { + var tr = TR(); + tr.push(TD((s.id))); + tr.push(TD((new Date(s.timestamp * 1000)).toString())); + tr.push(TD(s.value)); + t.push(tr); + }); + response.write(HTML(BODY(t))); +} + + +// function renderStat(body, statName) { +// var div = DIV({className: 'statbox'}); +// div.push(A({className: "stat-title", href: qpath({stat: statName})}, +// statName, descriptions[statName] || "")); +// if (_showHistStats()) { +// div.push( +// DIV({className: "stat-graph"}, +// A({href: '/ep/admin/usagestats/graph?size=1080x420&statName='+statName}, +// IMG({src: '/ep/admin/usagestats/graph?size=400x200&statName='+statName, +// style: 'border: 1px solid #ccc; margin: 10px 0 0 20px;'})), +// BR(), +// DIV({style: 'text-align: right;'}, +// A({style: 'text-decoration: none; font-size: .8em;', +// href: '/ep/admin/usagestats/data?statName='+statName}, "(data)"))) +// ); +// } +// if (_showLiveStats()) { +// var data = statistics.getStatData(statName); +// var displayData = statistics.liveSnapshot(data); +// var t = TABLE({border: 0}); +// var tcount = 0; +// ["minute", "hour", "fourHour", "day"].forEach(function(timescale) { +// if (! _showTimescale(timescale)) { return; } +// var tr = TR(); +// t.push(tr); +// tr.push(TD({valign: "top"}, B("Last ", timescale))); +// var td = TD(); +// var cell = SPAN(); +// tr.push(td); +// td.push(cell); +// switch (data.plotType) { +// case 'line': +// cell.push(B(displayData[timescale])); break; +// case 'topValues': +// var top = displayData[timescale].topValues; +// if (tcount != 0) { +// tr[0].attribs.style = cell.attribs.style = "border-top: 2px solid black;"; +// } +// // println(statName+" / top length: "+top.length); +// for (var i = 0; i < Math.min(_topN(), top.length); ++i) { +// cell.push(B(top[i].count), ": ", top[i].value, BR()); +// } +// break; +// case 'histogram': +// var percentiles = displayData[timescale]; +// var pcts = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; +// var max = percentiles["100"] || 1000; +// cell.push(IMG({src: "http://chart.apis.google.com/chart?chs=340x100&cht=bvs&chd=t:"+ +// pcts.map(function(pct) { return Math.round(percentiles[""+pct]/max*100); }).join(",")+ +// "&chxt=x,y&chxl=0:|"+ +// pcts.map(function(pct) { return ""+pct+"%"; }).join("|")+ +// "&chxr=0,0,100|1,0,"+max+""})) +// // td.push("50%: ", B(percentiles["50"]), " ", +// // "90%: ", B(percentiles["90"]), " ", +// // "max: ", B(percentiles["100"])); +// break; +// } +// tcount++; +// }); +// div.push(DIV({className: "stat-table"}, t)); +// div.push(html(helpers.clearFloats())); +// } +// body.push(div); +// } +// ======= +// >>>>>>> Stashed changes:etherpad/src/etherpad/control/statscontrol.js + + +// old output. + +// +// function getStatsForCategory(category) { +// var statnames = statistics.getAllStatNames(); +// +// var matchingStatNames = []; +// statnames.forEach(function(sn) { +// if (statistics.getStatData(sn).category == category) { +// matchingStatNames.push(sn); +// } +// }); +// +// return matchingStatNames; +// } +// +// function renderCategoryList() { +// var body = BODY(); +// +// catNames = getCategoryNames(); +// body.push(P("Please select a statistics category:")); +// catNames.sort().forEach(function(catname) { +// body.push(P(A({href: "/ep/admin/usagestats/?cat="+catname}, catname))); +// }); +// response.write(body); +// } +// +// function getCategoryNames() { +// var statnames = statistics.getAllStatNames(); +// var catNames = {}; +// statnames.forEach(function(sn) { +// catNames[statistics.getStatData(sn).category] = true; +// }); +// return keys(catNames); +// } +// +// function dropdown(name, options, selected) { +// var select; +// if (typeof(name) == 'string') { +// select = SELECT({name: name}); +// } else { +// select = SELECT(name); +// } +// +// function addOption(value, content) { +// var opt = OPTION({value: value}, content || value); +// if (value == selected) { +// opt.attribs.selected = "selected"; +// } +// select.push(opt); +// } +// +// if (options instanceof Array) { +// options.forEach(f_limitArgs(this, addOption, 1)); +// } else { +// eachProperty(options, addOption); +// } +// return select; +// } +// +// function getCategorizedStats() { +// var statnames = statistics.getAllStatNames(); +// var categories = {} +// statnames.forEach(function(sn) { +// var category = statistics.getStatData(sn).category +// if (! categories[category]) { +// categories[category] = []; +// } +// categories[category].push(statistics.getStatData(sn)); +// }); +// return categories; +// } +// +// function render_ajax() { +// var categoriesToStats = getCategorizedStats(); +// +// eachProperty(categoriesToStats, function(catName, statArray) { +// categoriesToStats[catName] = statArray.map(function(statObject) { +// return { +// specialState: "", +// displayName: statObject.name, +// name: statObject.name, +// data: liveStatDisplayHtml(statObject) +// } +// }) +// }); +// +// renderHtml('statistics/stat_page.ejs', +// {eachProperty: eachProperty, +// statCategoryNames: keys(categoriesToStats), +// categoriesToStats: categoriesToStats }); +// } + +// function render_main() { +// var body = BODY(); +// +// var statNames = statistics.getAllStatNames(); //getStatsForCategory(request.params.cat); +// statNames.forEach(function(sn) { +// renderStat(body, sn); +// }); +// response.write(body); +// } +// +// var descriptions = { +// execution_latencies: ", mean response time in milliseconds", +// static_file_latencies: ", mean response time in milliseconds", +// pad_startup_times: ", max response time in milliseconds of fastest N% of requests" +// }; +// +// function liveStatDisplayHtml(statObject) { +// var displayData = statistics.liveSnapshot(statObject); +// switch (statObject.plotType) { +// case 'line': +// return displayData; +// case 'topValues': +// var data = {} +// eachProperty(displayData, function(timescale, tsdata) { +// data[timescale] = "" +// var top = tsdata.topValues; +// for (var i = 0; i < Math.min(_topN(), top.length); ++i) { +// data[timescale] += [B(top[i].count), ": ", top[i].value, BR()].map(toHTML).join(""); +// } +// if (data[timescale] == "") { +// data[timescale] = "(no data)"; +// } +// }); +// return data; +// case 'histogram': +// var imgs = {} +// eachProperty(displayData, function(timescale, tsdata) { +// var percentiles = tsdata; +// var pcts = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; +// var max = percentiles["100"] || 1000; +// imgs[timescale] = +// toHTML(IMG({src: "http://chart.apis.google.com/chart?chs=400x100&cht=bvs&chd=t:"+ +// pcts.map(function(pct) { return Math.round(percentiles[""+pct]/max*100); }).join(",")+ +// "&chxt=x,y&chxl=0:|"+ +// pcts.map(function(pct) { return ""+pct+"%"; }).join("|")+ +// "&chxr=0,0,100|1,0,"+max+""})); +// }); +// return imgs; +// } +// } +// +// function renderStat(body, statName) { +// var div = DIV({style: 'float: left; text-align: center; margin: 3px; border: 1px solid black;'}) +// div.push(P(statName, descriptions[statName] || "")); +// if (_showLiveStats()) { +// var data = statistics.getStatData(statName); +// var displayData = statistics.liveSnapshot(data); +// var t = TABLE(); +// var tcount = 0; +// ["minute", "hour", "fourHour", "day"].forEach(function(timescale) { +// if (! _showTimescale(timescale)) { return; } +// var tr = TR(); +// t.push(tr); +// tr.push(TD("last ", timescale)); +// var td = TD(); +// tr.push(td); +// switch (data.plotType) { +// case 'line': +// td.push(B(displayData[timescale])); break; +// case 'topValues': +// var top = displayData[timescale].topValues; +// if (tcount != 0) { +// tr[0].attribs.style = td.attribs.style = "border-top: 1px solid gray;"; +// } +// // println(statName+" / top length: "+top.length); +// for (var i = 0; i < Math.min(_topN(), top.length); ++i) { +// td.push(B(top[i].count), ": ", top[i].value, BR()); +// } +// break; +// case 'histogram': +// var percentiles = displayData[timescale]; +// var pcts = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; +// var max = percentiles["100"] || 1000; +// td.push(IMG({src: "http://chart.apis.google.com/chart?chs=340x100&cht=bvs&chd=t:"+ +// pcts.map(function(pct) { return Math.round(percentiles[""+pct]/max*100); }).join(",")+ +// "&chxt=x,y&chxl=0:|"+ +// pcts.map(function(pct) { return ""+pct+"%"; }).join("|")+ +// "&chxr=0,0,100|1,0,"+max+""})) +// // td.push("50%: ", B(percentiles["50"]), " ", +// // "90%: ", B(percentiles["90"]), " ", +// // "max: ", B(percentiles["100"])); +// break; +// } +// tcount++; +// }); +// div.push(t) +// } +// if (_showHistStats()) { +// div.push(A({href: '/ep/admin/usagestats/graph?size=1080x420&statName='+statName}, +// IMG({src: '/ep/admin/usagestats/graph?size=400x200&statName='+statName, +// style: 'border: 1px solid #ccc; margin: 10px 0 0 20px;'})), +// BR(), +// DIV({style: 'text-align: right;'}, +// A({style: 'text-decoration: none; font-size: .8em;', +// href: '/ep/admin/usagestats/data?statName='+statName}, "(data)"))); +// } +// body.push(div); +// } +// +// function render_graph() { +// var sn = request.params.statName; +// if (!sn) { +// render404(); +// } +// usage_stats.respondWithGraph(sn); +// } +// +// +// function render_exceptions() { +// var logNames = ["frontend/exception", "backend/exceptions"]; +// } + +// function render_updatehistory() { +// +// sqlcommon.withConnection(function(conn) { +// var stmnt = "delete from statistics;"; +// var s = conn.createStatement(); +// sqlcommon.closing(s, function() { +// s.execute(stmnt); +// }); +// }); +// +// var processed = {}; +// +// function _domonth(y, m) { +// for (var i = 0; i < 32; i++) { +// _processStatsDay(y, m, i, processed); +// } +// } +// +// _domonth(2008, 10); +// _domonth(2008, 11); +// _domonth(2008, 12); +// _domonth(2009, 1); +// _domonth(2009, 2); +// _domonth(2009, 3); +// _domonth(2009, 4); +// _domonth(2009, 5); +// _domonth(2009, 6); +// _domonth(2009, 7); +// +// response.redirect('/ep/admin/usagestats'); +// } + +// function _processStatsDay(year, month, date, processed) { +// var now = new Date(); +// var day = new Date(); +// +// for (var i = 0; i < 10; i++) { +// day.setFullYear(year); +// day.setDate(date); +// day.setMonth(month-1); +// } +// +// if ((+day < +now) && +// (!((day.getFullYear() == now.getFullYear()) && +// (day.getMonth() == now.getMonth()) && +// (day.getDate() == now.getDate())))) { +// +// var dayNoon = statistics.noon(day); +// +// if (processed[dayNoon]) { +// return; +// } else { +// statistics.processLogDay(new Date(dayNoon)); +// processed[dayNoon] = true; +// } +// } else { +// /* nothing */ +// } +// } + diff --git a/trunk/etherpad/src/etherpad/control/store/eepnet_checkout_control.js b/trunk/etherpad/src/etherpad/control/store/eepnet_checkout_control.js new file mode 100644 index 0000000..ddd4973 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/store/eepnet_checkout_control.js @@ -0,0 +1,757 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("email.sendEmail"); +import("fastJSON"); +import("funhtml.*"); +import("jsutils.*"); +import("sqlbase.sqlobj"); +import("stringutils"); +import("sync"); + +import("etherpad.billing.billing"); +import("etherpad.billing.fields"); +import("etherpad.globals"); +import("etherpad.globals.*"); +import("etherpad.helpers"); +import("etherpad.licensing"); +import("etherpad.pro.pro_utils"); +import("etherpad.sessions.{getSession,getTrackingId,getSessionId}"); +import("etherpad.store.checkout"); +import("etherpad.store.eepnet_checkout"); +import("etherpad.utils.*"); + +import("static.js.billing_shared.{billing=>billingJS}"); + +jimport("java.lang.System.out.println"); + +//---------------------------------------------------------------- + +var STORE_URL = '/ep/store/eepnet-checkout/'; + +var _pageSequence = [ + ['purchase', "Number of Users", true], + ['support-contract', "Support Contract", true], + ['license-info', "License Information", true], + ['billing-info', "Billing Information", true], + ['confirmation', "Confirmation", false] +]; + +var _specialPages = { + 'receipt': ['receipt', "Receipt", false] +} + +//---------------------------------------------------------------- + +function _cart() { + return getSession().eepnetCart; +} + +function _currentPageSegment() { + return request.path.split('/')[4]; +} + +function _currentPageId() { + return _applyToCurrentPageSequenceEntry(function(ps) { return ps[0]; }); +} + +function _applyToCurrentPageSequenceEntry(f) { + for (var i = 0; i < _pageSequence.length; i++) { + if (_pageSequence[i][0] == _currentPageSegment()) { + return f(_pageSequence[i], i, true); + } + } + if (_specialPages[_currentPageSegment()]) { + return f(_specialPages[_currentPageSegment()], -1, false); + } + return undefined; +} + +function _currentPageIndex() { + return _applyToCurrentPageSequenceEntry(function(ps, i) { return i; }); +} + +function _currentPageTitle() { + return _applyToCurrentPageSequenceEntry(function(ps) { return ps[1]; }); +} + +function _currentPageShowCart() { + return _applyToCurrentPageSequenceEntry(function(ps) { return ps[2]; }); +} + +function _currentPageInFlow() { + return _applyToCurrentPageSequenceEntry(function(ps, i, isSpecial) { return isSpecial }); +} + +function _pageId(d) { + return _applyToCurrentPageSequenceEntry(function(ps, i) { + if (_pageSequence[i+d]) { + return _pageSequence[i+d][0]; + } + }); +} + +function _nextPageId() { return _pageId(+1); } +function _prevPageId() { return _pageId(-1); } + +function _advancePage() { + response.redirect(_pathTo(_nextPageId())); +} + +function _pathTo(id) { + return STORE_URL+id; +} + +// anything starting with 'billing' is also ok. +function _isAutomaticallySetParam(p) { + var _automaticallySetParams = arrayToSet([ + 'numUsers', 'couponCode', 'supportContract', + 'email', 'ownerName', 'orgName', 'licenseAgreement' + ]); + + return _automaticallySetParams[p] || stringutils.startsWith(p, "billing"); +} + +function _lastSubmittedPage() { + var cart = _cart(); + return isNaN(cart.lastSubmittedPage) ? -1 : Number(cart.lastSubmittedPage); +} + +function _shallowSafeCopy(obj) { + return billing.clearKeys(obj, [ + {name: 'billingCCNumber', + valueTest: function(s) { return /^\d{15,16}$/.test(s) }, + valueReplace: billing.replaceWithX }, + {name: 'billingCSC', + valueTest: function(s) { return /^\d{3,4}$/.test(s) }, + valueReplace: billing.replaceWithX }]); +} + +function onRequest() { + billing.log({ + 'type': "billing-request", + 'date': +(new Date), + 'method': request.method, + 'path': request.path, + 'query': request.query, + 'host': request.host, + 'scheme': request.scheme, + 'params': _shallowSafeCopy(request.params), + 'cart': _shallowSafeCopy(_cart()) + }); + if (request.path == STORE_URL+"paypalnotify") { + _handlePaypalNotification(); + } + if (request.path == STORE_URL+"paypalredirect") { + _handlePayPalRedirect(); + } + var cart = _cart(); + if (!cart || request.params.clearcart) { + getSession().eepnetCart = { + lastSubmittedPage: -1, + invoiceId: billing.createInvoice() + }; + if (request.params.clearcart) { + response.redirect(request.path); + } + if (_currentPageId() != 'purchase') { + response.redirect(_pathTo('purchase')); + } + cart = _cart(); + } + if (request.params.invoice) { + cart.billingPurchaseType = 'invoice'; + } + if (cart.purchaseComplete && _currentPageId() != 'receipt') { + cart.showStartOverMessage = true; + response.redirect(_pathTo('receipt')); + } + // somehow user got too far? + if (_currentPageIndex() > _lastSubmittedPage() + 1) { + response.redirect(_pathTo(_pageSequence[_lastSubmittedPage()+1][0])); + } + if (request.isGet) { + // see if this is a standard cart-page get + if (_currentPageId()) { + _renderCartPage(); + return true; + } + } + if (request.isPost) { + // add params to cart + eachProperty(request.params, function(k,v) { + if (! _isAutomaticallySetParam(k)) { return; } + if (k == "billingCCNumber" && v.charAt(0) == 'X') { return; } + cart[k] = stringutils.toHTML(v); + }); + if (_currentPageId() == 'license-info' && ! request.params.licenseAgreement) { + delete cart.licenseAgreement; + } + if (_currentPageIndex() > cart.lastSubmittedPage) { + cart.lastSubmittedPage = _currentPageIndex(); + } + } + if (request.params.backbutton) { + _updateCosts(); + response.redirect(_pathTo(_prevPageId())); + } + return false; // commence auto-dispatch +} + +function _getCoupon(code) { + return sqlobj.selectSingle('checkout_referral', {id: code}); +} + +function _supportCost() { + var cart = _cart(); + return Math.max(eepnet_checkout.SUPPORT_MIN_COST, eepnet_checkout.SUPPORT_COST_PCT/100*cart.baseCost); +} + +function _discountedSupportCost() { + var cart = _cart(); + if ('couponSupportPctDiscount' in cart) { + return _supportCost() - + (cart.couponSupportPctDiscount ? + cart.couponSupportPctDiscount/100 * _supportCost() : + 0); + } +} + +function _updateCosts() { + var cart = _cart(); + + if (cart.numUsers) { + cart.numUsers = Number(cart.numUsers); + + cart.baseCost = cart.numUsers * eepnet_checkout.COST_PER_USER; + + if (cart.supportContract == "true") { + cart.supportCost = _supportCost(); + } else { + delete cart.supportCost; + } + + var coupon = _getCoupon(cart.couponCode); + if (coupon) { + for (i in coupon) { + cart["coupon"+stringutils.makeTitle(i)] = coupon[i]; + } + cart.coupon = coupon; + } else { + for (i in cart.coupon) { + delete cart["coupon"+stringutils.makeTitle(i)]; + } + delete cart.coupon; + } + + if (cart.couponProductPctDiscount) { + cart.productReferralDiscount = + cart.couponProductPctDiscount/100 * cart.baseCost; + } else { + delete cart.productReferralDiscount; + } + if (cart.couponSupportPctDiscount) { + cart.supportReferralDiscount = + cart.couponSupportPctDiscount/100 * (cart.supportCost || 0); + } else { + delete cart.supportReferralDiscount; + } + cart.subTotal = + cart.baseCost - (cart.productReferralDiscount || 0) + + (cart.supportCost || 0) - (cart.supportReferralDiscount || 0); + + if (cart.couponTotalPctDiscount) { + cart.totalReferralDiscount = + cart.couponTotalPctDiscount/100 * cart.subTotal; + } else { + delete cart.totalReferralDiscount; + } + + if (cart.couponFreeUsersCount || cart.couponFreeUsersPct) { + cart.freeUserCount = + Math.round(cart.couponFreeUsersCount + + cart.couponFreeUsersPct/100 * cart.numUsers); + } else { + delete cart.freeUserCount; + } + cart.userCount = Number(cart.numUsers) + Number(cart.freeUserCount || 0); + + cart.total = + cart.subTotal - (cart.totalReferralDiscount || 0); + } +} + +//---------------------------------------------------------------- +// template helper functions +//---------------------------------------------------------------- + +function _cartDebug() { + if (globals.isProduction()) { + return ''; + } + + var d = DIV({style: 'font-family: monospace; font-size: 1em; border: 1px solid #ccc; padding: 1em; margin: 1em;'}); + d.push(H3({style: "font-size: 1.5em; font-weight: bold;"}, "Debug Info:")); + var t = TABLE({border: 1, cellspacing: 0, cellpadding: 4}); + keys(_cart()).sort().forEach(function(k) { + var v = _cart()[k]; + if (typeof(v) == 'object' && v != null) { + v = v.toSource(); + } + t.push(TR(TD({style: 'padding: 2px 6px;', align: 'right'}, k), + TD({style: 'padding: 2px 6px;', align: 'left'}, v))); + }); + d.push(t); + return d; +} + +var billingButtonName = "Review Order"; + +function _templateContext(extra) { + var cart = _cart(); + + var pageId = _currentPageId(); + + var ret = { + cart: cart, + costPerUser: eepnet_checkout.COST_PER_USER, + supportCostPct: eepnet_checkout.SUPPORT_COST_PCT, + supportMinCost: eepnet_checkout.SUPPORT_MIN_COST, + errorIfInvalid: _errorIfInvalid, + dollars: checkout.dollars, + countryList: fields.countryList, + usaStateList: fields.usaStateList, + obfuscateCC: checkout.obfuscateCC, + helpers: helpers, + inFlow: _currentPageInFlow(), + displayCart: _displayCart, + displaySummary: _displaySummary, + pathTo: _pathTo, + billing: billingJS, + handlePayPalRedirect: _handlePayPalRedirect, + supportCost: _supportCost, + discountedSupportCost: _discountedSupportCost, + billingButtonName: billingButtonName, + billingFinalPhrase: "<p>You will not be charged until you review"+ + " and confirm your order on the next page.</p>", + getFullSuperdomainHost: pro_utils.getFullSuperdomainHost, + showCouponCode: false + }; + eachProperty(extra, function(k, v) { + ret[k] = v; + }); + return ret; +} + +function _displayCart(cartid, editable) { + return renderTemplateAsString('store/eepnet-checkout/cart.ejs', _templateContext({ + shoppingcartid: cartid || "shoppingcart", + editable: editable + })); +} + +function _displaySummary(editable) { + return renderTemplateAsString('store/eepnet-checkout/summary.ejs', _templateContext({ + editable: editable + })); +} + +function _renderCartPage() { + var cart = _cart(); + + var pageId = _currentPageId(); + var title = _currentPageTitle(); + + function _getContent() { + return renderTemplateAsString('store/eepnet-checkout/'+pageId+'.ejs', _templateContext()); + } + + renderFramed('store/eepnet-checkout/checkout-template.ejs', { + cartDebug: _cartDebug, + errorDiv: _errorDiv, + pageId: pageId, + getContent: _getContent, + title: title, + inFlow: _currentPageInFlow(), + displayCart: _displayCart, + showCart: _currentPageShowCart(), + cart: cart, + billingButtonName: billingButtonName + }); + + // clear errors + delete cart.errorMsg; + delete cart.errorId; +} + +function _errorDiv() { + var m = _cart().errorMsg; + if (m) { + return DIV({className: 'errormsg', id: 'errormsg'}, m); + } else { + return ''; + } +} + +function _errorIfInvalid(id) { + var e = _cart().errorId + if (e && e[id]) { + return 'error'; + } else { + return ''; + } +} + +function _validationError(id, msg, pageId) { + var cart = _cart(); + cart.errorMsg = msg; + cart.errorId = {}; + if (id instanceof Array) { + id.forEach(function(k) { + cart.errorId[k] = true; + }); + } else { + cart.errorId[id] = true; + } + if (pageId) { + response.redirect(_pathTo(pageId)); + } + response.redirect(request.path); +} + +//-------------------------------------------------------------------------------- +// main +//-------------------------------------------------------------------------------- + +function render_main() { + response.redirect(STORE_URL+'purchase'); +} + +//-------------------------------------------------------------------------------- +// cart +//-------------------------------------------------------------------------------- + +function render_purchase_post() { + var cart = _cart(); + + // validate numUsers and couponCode + if (! checkout.isOnlyDigits(cart.numUsers)) { + _validationError("numUsers", "Please enter a valid number of users."); + } + if (Number(cart.numUsers) < 1) { + _validationError("numUsers", "Please specify at least one user."); + } + + if (cart.couponCode && (cart.couponCode.length != 8 || ! _getCoupon(cart.couponCode))) { + _validationError("couponCode", "That coupon code does not appear to be valid."); + } + + _updateCosts(); + _advancePage(); +} + +//-------------------------------------------------------------------------------- +// support-contract +//-------------------------------------------------------------------------------- + +function render_support_contract_post() { + var cart = _cart(); + + if (cart.supportContract != "true" && cart.supportContract != "false") { + _validationError("supportContract", "Please select one of the options."); + } + + _updateCosts(); + _advancePage(); +} + +//-------------------------------------------------------------------------------- +// license-info +//-------------------------------------------------------------------------------- + +function render_license_info_post() { + var cart = _cart(); + + if (!isValidEmail(cart.email)) { + _validationError("email", "That email address does not look valid."); + } + if (!cart.ownerName) { + _validationError("ownerName", "Please enter a license owner name."); + } + if (!cart.orgName) { + _validationError("orgName", "Please enter an organization name."); + } + if (!cart.licenseAgreement) { + _validationError("licenseAgreement", "You must agree to the terms of the license to purchase EtherPad PNE."); + } + + if ((! cart.billingFirstName) && ! (cart.billingLastName)) { + var nameParts = cart.ownerName.split(/\s+/); + if (nameParts.length == 1) { + cart.billingFirstName = nameParts[0]; + } else { + cart.billingLastName = nameParts[nameParts.length-1]; + cart.billingFirstName = nameParts.slice(0, nameParts.length-1).join(' '); + } + } + + _updateCosts(); + _advancePage(); +} + +//-------------------------------------------------------------------------------- +// billing-info +//-------------------------------------------------------------------------------- + +function render_billing_info_post() { + var cart = _cart(); + + checkout.validateBillingCart(_validationError, cart); + if (cart.billingPurchaseType == 'paypal') { + _beginPaypalPurchase(); + } + + _updateCosts(); + _advancePage(); +} + +function _absoluteUrl(id) { + return request.scheme+"://"+request.host+_pathTo(id); +} + +function _beginPaypalPurchase() { + _updateCosts(); + + var cart = _cart(); + + var purchase = _generatePurchaseRecord(); + var result = + billing.beginExpressPurchase(cart.invoiceId, cart.customerId, + "EEPNET", cart.total || 0.01, cart.couponCode || "", + _absoluteUrl('paypalredirect?status=ok'), + _absoluteUrl('paypalredirect?status=fail'), + _absoluteUrl('paypalnotify')); + if (result.status != 'success') { + _validationError("billingPurchaseType", + "PayPal purchase not available at the moment. "+ + "Please try again later, or try using a different payment option."); + } + cart.paypalPurchaseInfo = result.purchaseInfo; + response.redirect(billing.paypalPurchaseUrl(result.purchaseInfo.token)); +} + +//-------------------------------------------------------------------------------- +// confirmation +//-------------------------------------------------------------------------------- + +function _handlePaypalNotification() { + var ret = billing.handlePaypalNotification(); + if (ret.status == 'completion') { + var purchaseInfo = ret.purchaseInfo; + var eepnetPurchase = eepnet_checkout.getPurchaseByInvoiceId(purchaseInfo.invoiceId); + var fakeCart = { + ownerName: eepnetPurchase.owner, + orgName: eepnetPurchase.organization, + email: eepnetPurchase.emails, + customerId: eepnetPurchase.id, + userCount: eepnetPurchase.numUsers, + receiptEmail: eepnetPurchase.receiptEmail, + } + eepnet_checkout.generateLicenseKey(fakeCart); + eepnet_checkout.sendReceiptEmail(fakeCart); + eepnet_checkout.sendLicenseEmail(fakeCart); + billing.log({type: 'purchase-complete', dollars: purchaseInfo.cost}); + } +} + +function _handlePayPalRedirect() { + var cart = _cart(); + + if (request.params.status == 'ok' && cart.paypalPurchaseInfo) { + var result = billing.continueExpressPurchase(cart.paypalPurchaseInfo); + if (result.status == 'success') { + cart.paypalPayerInfo = result.payerInfo; + response.redirect(_pathTo('confirmation')); + } else { + _validationError("billingPurchaseType", + "There was an error processing your payment through PayPal. "+ + "Please try again later, or use a different payment option.", + 'billing-info'); + } + } else { + _validationError("billingPurchaseType", + "PayPal payment didn't go through. "+ + "Please try again later, or use a different payment option.", + 'billing-info'); + } +} + +function _recordPurchase(p) { + return sqlobj.insert("checkout_purchase", p); +} + +function _generatePurchaseRecord() { + var cart = _cart(); + + if (! cart.invoiceId) { + throw Error("No invoice id!"); + } + + var purchase = { + invoiceId: cart.invoiceId, + email: cart.email, + firstName: cart.billingFirstName, + lastName: cart.billingLastName, + owner: cart.ownerName || "", + organization: cart.orgName || "", + addressLine1: cart.billingAddressLine1 || "", + addressLine2: cart.billingAddressLine2 || "", + city: cart.billingCity || "", + state: cart.billingState || "", + zip: cart.billingZipCode || "", + referral: cart.couponCode, + cents: cart.total*100, // cents here. + numUsers: cart.userCount, + purchaseType: cart.billingPurchaseType, + } + cart.customerId = _recordPurchase(purchase); + return purchase; +} + +function _performCreditCardPurchase() { + var cart = _cart(); + var purchase = _generatePurchaseRecord(); + var payInfo = checkout.generatePayInfo(cart); + + // log everything but the CVV, which we're not allowed to store + // any longer than it takes to process this transaction. + var savedCvv = payInfo.cardCvv; + delete payInfo.cardCvv; + checkout.writeToEncryptedLog(fastJSON.stringify({date: String(new Date()), purchase: purchase, customerId: cart.customerId, payInfo: payInfo})); + payInfo.cardCvv = savedCvv; + + var result = + billing.directPurchase(cart.invoiceId, cart.customerId, + "EEPNET", cart.total || 0.01, + cart.couponCode || "", + payInfo, _absoluteUrl('paypalnotify')); + + if (result.status == 'success') { + cart.status = 'success'; + cart.purchaseComplete = true; + eepnet_checkout.generateLicenseKey(cart); + eepnet_checkout.sendReceiptEmail(cart); + eepnet_checkout.sendLicenseEmail(cart); + billing.log({type: 'purchase-complete', dollars: cart.total, + email: cart.email, user: cart.ownerName, + org: cart.organization}); + // TODO: generate key and include in receipt page, and add to purchase table. + } else if (result.status == 'pending') { + cart.status = 'pending'; + cart.purchaseComplete = true; + eepnet_checkout.sendReceiptEmail(cart); + // save the receipt email text to resend later. + eepnet_checkout.updatePurchaseWithReceipt(cart.customerId, + eepnet_checkout.receiptEmailText(cart)); + } else if (result.status == 'failure') { + var paypalResult = result.debug; + billing.log({'type': 'FATAL', value: "Direct purchase failed on paypal.", cart: cart, paypal: paypalResult}); + if (result.errorField.permanentErrors[0] == 'invoiceId') { + // repeat invoice id. damnit, this is bad. + sendEmail('support@pad.spline.inf.fu-berlin.de', 'urgent@pad.spline.inf.fu-berlin.de', 'DUPLICATE INVOICE WARNING!', {}, + "Hey,\n\nThis is a billing system error. The EEPNET checkout tried to make a "+ + "purchase with PayPal and got a duplicate invoice error on invoice ID "+cart.invoiceId+ + ".\n\nUnless you're expecting this (or recently ran a selenium test, or have reason to "+ + "believe this isn't an exceptional condition, please look into this "+ + "and get back to the user ASAP!\n\n"+fastJSON.stringify(cart)); + _validationError('', "Your payment was processed, but we cannot proceed. "+ + "You will hear from us shortly via email. (If you don't hear from us "+ + "within 24 hours, please email <a href='mailto:sales@pad.spline.inf.fu-berlin.de'>"+ + "sales@pad.spline.inf.fu-berlin.de</a>.)"); + } + checkout.validateErrorFields(function(x, y) { _validationError(x, y, 'billing-info') }, "There seems to be an error in your billing information."+ + " Please verify and correct your ", + result.errorField.userErrors); + checkout.validateErrorFields(function(x, y) { _validationError(x, y, 'billing-info') }, "The bank declined your billing information. Please try a different ", + result.errorField.permanentErrors); + _validationError('', "A temporary error has prevented processing of your payment. Please try again later."); + } else { + billing.log({'type': 'FATAL', value: "Unknown error: "+result.status+" - debug: "+result.debug}); + sendEmail('support@pad.spline.inf.fu-berlin.de', 'urgent@pad.spline.inf.fu-berlin.de', 'UNKNOWN ERROR WARNING!', {}, + "Hey,\n\nThis is a billing system error. Some unknown error occurred. "+ + "This shouldn't ever happen. Probably good to let J.D. know. <grin>\n\n"+ + fastJSON.stringify(cart)); + _validationError('', "An unknown error occurred. We're looking into it!") + } +} + +function _completePaypalPurchase() { + var cart = _cart(); + var purchaseInfo = cart.paypalPurchaseInfo; + var payerInfo = cart.paypalPayerInfo; + + var result = billing.completeExpressPurchase(purchaseInfo, payerInfo, _absoluteUrl('paypalnotify')); + if (result.status == 'success') { + cart.status = 'success'; + cart.purchaseComplete = true; + eepnet_checkout.generateLicenseKey(cart); + eepnet_checkout.sendReceiptEmail(cart); + eepnet_checkout.sendLicenseEmail(cart); + billing.log({type: 'purchase-complete', dollars: cart.total, + email: cart.email, user: cart.ownerName, + org: cart.organization}); + + } else if (result.status == 'pending') { + cart.status = 'pending'; + cart.purchaseComplete = true; + eepnet_checkout.sendReceiptEmail(cart); + // save the receipt email text to resend later. + eepnet_checkout.updatePurchaseWithReceipt(cart.customerId, + eepnet_checkout.receiptEmailText(cart)); + } else { + billing.log({'type': 'FATAL', value: "Paypal failed.", cart: cart, paypal: paypalResult}); + _validationError("billingPurchaseType", + "There was an error processing your payment through PayPal. "+ + "Please try again later, or use a different payment option.", + 'billing-info'); + } +} + +function _showReceipt() { + response.redirect(_pathTo('receipt')); +} + +function render_confirmation_post() { + var cart = _cart(); + + _updateCosts(); // no fishy business, please. + + if (cart.billingPurchaseType == 'creditcard') { + _performCreditCardPurchase(); + _showReceipt(); + } else if (cart.billingPurchaseType == 'paypal') { + _completePaypalPurchase(); + _showReceipt(); + } +} + +//-------------------------------------------------------------------------------- +// receipt +//-------------------------------------------------------------------------------- + +function render_receipt_post() { + response.redirect(request.path); +} diff --git a/trunk/etherpad/src/etherpad/control/store/storecontrol.js b/trunk/etherpad/src/etherpad/control/store/storecontrol.js new file mode 100644 index 0000000..43569e4 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/store/storecontrol.js @@ -0,0 +1,201 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("dispatch.{Dispatcher,DirMatcher,forward}"); +import("fastJSON"); +import("funhtml.*"); + +import('etherpad.globals.*'); +import("etherpad.store.eepnet_trial"); +import("etherpad.store.eepnet_checkout"); +import("etherpad.sessions.getSession"); +import("etherpad.utils.*"); + +import("etherpad.control.store.eepnet_checkout_control"); +import("etherpad.control.pro.admin.team_billing_control"); + +jimport("java.lang.System.out.println"); + +//---------------------------------------------------------------- + +function onStartup() {} + +function onRequest() { + var disp = new Dispatcher(); + disp.addLocations([ + [DirMatcher('/ep/store/eepnet-checkout/'), forward(eepnet_checkout_control)], + ]); + return disp.dispatch(); +} + +//---------------------------------------------------------------- + +function render_main() { + response.redirect("/ep/about/pricing"); +} + +//---------------------------------------------------------------- +// Flow goes through these 4 pages in order: +//---------------------------------------------------------------- + +function render_eepnet_eval_signup_get() { + renderFramed("store/eepnet_eval_signup.ejs", { + trialDays: eepnet_trial.getTrialDays(), + oldData: (getSession().pricingContactData || {}), + sfIndustryList: eepnet_trial.getSalesforceIndustryList() + }); + delete getSession().errorMsg; +} + +// function render_eepnet_eval_signup_post() { +// response.setContentType("text/plain; charset=utf-8"); +// var data = {}; +// var fields = ['firstName', 'lastName', 'email', 'orgName', +// 'jobTitle', 'phone', 'estUsers', 'industry']; +// +// if (!getSession().pricingContactData) { +// getSession().pricingContactData = {}; +// } +// +// function _redirectErr(msg) { +// response.write(fastJSON.stringify({error: msg})); +// response.stop(); +// } +// +// fields.forEach(function(f) { +// getSession().pricingContactData[f] = request.params[f]; +// }); +// +// fields.forEach(function(f) { +// data[f] = request.params[f]; +// if (!(data[f] && (data[f].length > 0))) { +// _redirectErr("All fields are required."); +// } +// }); +// +// // validate email +// if (!isValidEmail(data.email)) { +// _redirectErr("That email address doesn't look valid."); +// } +// +// // check that email not already registered. +// if (eepnet_trial.hasEmailAlreadyDownloaded(data.email)) { +// _redirectErr("That email has already downloaded a free trial."+ +// ' <a href="/ep/store/eepnet-recover-license">Recover a lost license key here</a>.'); +// } +// +// // Looks good! Create and email license key... +// eepnet_trial.createAndMailNewLicense(data); +// getSession().message = "A license key has been sent to "+data.email; +// +// // Generate web2lead info and return it +// var web2leadData = eepnet_trial.getWeb2LeadData(data, request.clientAddr, getSession().initialReferer); +// response.write(fastJSON.stringify(web2leadData)); +// } +// +// function render_salesforce_web2lead_ok() { +// renderFramedHtml([ +// '<script>', +// 'top.location.href = "'+request.scheme+'://'+request.host+'/ep/store/eepnet-download";', +// '</script>' +// ].join('\n')); +// } +// +// function render_eepnet_eval_download() { +// // NOTE: keep this URL around for historical reasons? +// response.redirect("/ep/store/eepnet-download"); +// } +// +// function render_eepnet_download() { +// renderFramed("store/eepnet_download.ejs", { +// message: (getSession().message || null), +// versionString: (PNE_RELEASE_VERSION+" ("+PNE_RELEASE_DATE +")") +// }); +// delete getSession().message; +// } +// +// function render_eepnet_download_zip() { +// response.redirect("/static/zip/pne-release/etherpad-pne-"+PNE_RELEASE_VERSION+".zip"); +// } +// +// function render_eepnet_download_nextsteps() { +// renderFramed("store/eepnet_eval_nextsteps.ejs"); +// } + +//---------------------------------------------------------------- +// recover a lost license +//---------------------------------------------------------------- +function render_eepnet_recover_license_get() { + var d = DIV({className: "fpcontent"}); + + d.push(P("Recover your lost license key.")); + + if (getSession().message) { + d.push(DIV({id: "resultmsg", + style: "border: 1px solid #333; padding: 0 1em; background: #efe; margin: 1em 0;"}, getSession().message)); + delete getSession().message; + } + if (getSession().error) { + d.push(DIV({id: "errormsg", + style: "border: 1px solid red; padding: 0 1em; background: #fee; margin: 1em 0;"}, getSession().error)); + delete getSession().error; + } + + d.push(FORM({style: "border: 1px solid #222; padding: 2em; background: #eee;", + action: request.path, method: "post"}, + LABEL({htmlFor: "email"}, + "Your email address:"), + INPUT({type: "text", name: "email", id: "email"}), + INPUT({type: "submit", id: "submit", value: "Submit"}))); + + renderFramedHtml(d); +} + +function render_eepnet_recover_license_post() { + var email = request.params.email; + if (!eepnet_trial.hasEmailAlreadyDownloaded(email) && !eepnet_trialhasEmailAlreadyPurchased(email)) { + getSession().error = P("License not found for email: \"", email, "\"."); + response.redirect(request.path); + } + if (eepnet_checkout.hasEmailAlreadyPurchased(email)) { + eepnet_checkout.mailLostLicense(email); + } else if (eepnet_trial.hasEmailAlreadyDownloaded(email)) { + eepnet_trial.mailLostLicense(email); + } + getSession().message = P("Your license information has been sent to ", email, "."); + response.redirect(request.path); +} + +//---------------------------------------------------------------- +function render_eepnet_purchase_get() { + renderFramed("store/eepnet_purchase.ejs", {}); +} + +//-------------------------------------------------------------------------------- +// csc-help page +//-------------------------------------------------------------------------------- + +function render_csc_help_get() { + response.write(renderTemplateAsString("store/csc-help.ejs")); +} + +//-------------------------------------------------------------------------------- +// paypal notifications for pro +//-------------------------------------------------------------------------------- + +function render_paypalnotify() { + team_billing_control.handlePaypalNotify(); +} diff --git a/trunk/etherpad/src/etherpad/control/testcontrol.js b/trunk/etherpad/src/etherpad/control/testcontrol.js new file mode 100644 index 0000000..ed13006 --- /dev/null +++ b/trunk/etherpad/src/etherpad/control/testcontrol.js @@ -0,0 +1,74 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.globals.*"); +import("etherpad.utils.*"); + +jimport("java.lang.System.out.println"); + +//---------------------------------------------------------------- +var tests = [ + "t0000_test", + "t0001_sqlbase_transaction_rollback", + "t0002_license_generation", + "t0003_persistent_vars", + "t0004_sqlobj", + "t0005_easysync" +]; + +var tscope = this; +tests.forEach(function(t) { + import.call(tscope, 'etherpad.testing.unit_tests.'+t); +}); +//---------------------------------------------------------------- + +function _testName(x) { + x = x.replace(/^t\d+\_/, ''); + return x; +} + +function render_run() { + response.setContentType("text/plain; charset=utf-8"); + if (isProduction() && (request.params.p != "waverunner")) { + response.write("access denied"); + response.stop(); + } + + var singleTest = request.params.t; + var numRun = 0; + + println("----------------------------------------------------------------"); + println("running tests"); + println("----------------------------------------------------------------"); + tests.forEach(function(t) { + var testName = _testName(t); + if (singleTest && (singleTest != testName)) { + return; + } + println("running test: "+testName); + numRun++; + tscope[t].run(); + println("|| pass ||"); + }); + println("----------------------------------------------------------------"); + + if (numRun == 0) { + response.write("Error: no tests found"); + } else { + response.write("OK"); + } +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0000_test.js b/trunk/etherpad/src/etherpad/db_migrations/m0000_test.js new file mode 100644 index 0000000..7df9bfd --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0000_test.js @@ -0,0 +1,23 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +function run() { + // nothing +} + + + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0001_eepnet_signups_init.js b/trunk/etherpad/src/etherpad/db_migrations/m0001_eepnet_signups_init.js new file mode 100644 index 0000000..0e65779 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0001_eepnet_signups_init.js @@ -0,0 +1,38 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon"); +import("etherpad.utils.isPrivateNetworkEdition"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + sqlobj.createTable('eepnet_signups', { + id: 'INT NOT NULL '+sqlcommon.autoIncrementClause()+' PRIMARY KEY', + email: 'VARCHAR(128) NOT NULL UNIQUE', + date: 'TIMESTAMP', + signupIp: 'VARCHAR(16)', + fullName: 'VARCHAR(255) NOT NULL', + orgName: 'VARCHAR(255) NOT NULL', + jobTitle: 'VARCHAR(255) NOT NULL', + estUsers: 'VARCHAR(255) NOT NULL', + licenseKey: 'VARCHAR(1024) NOT NULL' + }); +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0002_eepnet_signups_2.js b/trunk/etherpad/src/etherpad/db_migrations/m0002_eepnet_signups_2.js new file mode 100644 index 0000000..786e4e9 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0002_eepnet_signups_2.js @@ -0,0 +1,47 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("etherpad.utils.isPrivateNetworkEdition"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + // add new columns. + sqlobj.addColumns('eepnet_signups', { + firstName: 'VARCHAR(128) NOT NULL DEFAULT \'\'', + lastName: 'VARCHAR(128) NOT NULL DEFAULT \'\'', + phone: 'VARCHAR(128) NOT NULL DEFAULT \'\'' + }); + + // split name into first/last + var rows = sqlobj.selectMulti('eepnet_signups', {}, {}); + rows.forEach(function(r) { + var name = r.fullName; + r.firstName = (r.fullName.split(' ')[0]) || "?"; + r.lastName = (r.fullName.split(' ').slice(1).join(' ')) || "?"; + r.phone = "?"; + sqlobj.updateSingle('eepnet_signups', {id: r.id}, r); + }); + + // drop column fullName + sqlobj.dropColumn('eepnet_signups', 'fullName'); +} + + + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0003_create_tests_table_v2.js b/trunk/etherpad/src/etherpad/db_migrations/m0003_create_tests_table_v2.js new file mode 100644 index 0000000..f121145 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0003_create_tests_table_v2.js @@ -0,0 +1,29 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon"); +import("etherpad.utils.isPrivateNetworkEdition"); + +function run() { + if (sqlcommon.doesTableExist('just_a_test')) { + sqlobj.dropTable('just_a_test'); + } + sqlobj.createTable('just_a_test', { + id: 'INT NOT NULL '+sqlcommon.autoIncrementClause()+' PRIMARY KEY', + x: 'VARCHAR(128)' + }); +} diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0004_convert_all_tables_to_innodb.js b/trunk/etherpad/src/etherpad/db_migrations/m0004_convert_all_tables_to_innodb.js new file mode 100644 index 0000000..959865d --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0004_convert_all_tables_to_innodb.js @@ -0,0 +1,38 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); + +import('etherpad.db_migrations.migration_runner.dmesg'); + +function run() { + // This migration only applies to MySQL + if (!sqlcommon.isMysql()) { + return; + } + + var tables = sqlobj.listTables(); + tables.forEach(function(t) { + if (sqlobj.getTableEngine(t) != "InnoDB") { + dmesg("Converting table "+t+" to InnoDB..."); + sqlobj.setTableEngine(t, "InnoDB"); + } + }); + +}; + + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0005_create_billing_tables.js b/trunk/etherpad/src/etherpad/db_migrations/m0005_create_billing_tables.js new file mode 100644 index 0000000..0dfd37e --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0005_create_billing_tables.js @@ -0,0 +1,73 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.utils.isPrivateNetworkEdition"); +import("sqlbase.sqlobj"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + var idColspec = "INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY"; + + sqlobj.createTable('billing_purchase', { + id: idColspec, + type: "ENUM('onetimepurchase', 'subscription')", + customer: "INT(11) NOT NULL", + product: "VARCHAR(128) NOT NULL", + cost: "INT(11) NOT NULL", + coupon: "VARCHAR(128) NOT NULL", + time: "DATETIME", + paidThrough: "DATETIME", + status: "ENUM('active', 'inactive')" + }, { + type: true, + customer: true, + product: true + }); + + sqlobj.createTable('billing_invoice', { + id: idColspec, + time: "DATETIME", + purchase: "INT(11) NOT NULL", + amt: "INT(11) NOT NULL", + status: "ENUM('pending', 'paid', 'void', 'refunded')" + }, { + status: true + }); + + sqlobj.createTable('billing_transaction', { + id: idColspec, + customer: "INT(11)", + time: "DATETIME", + amt: "INT(11)", + payInfo: "VARCHAR(128)", + txnId: "VARCHAR(128)", // depends on gateway used? + status: "ENUM('new', 'success', 'failure', 'pending')" + }, { + customer: true, + txnId: true + }); + + sqlobj.createTable('billing_adjustment', { + id: idColspec, + transaction: "INT(11)", + invoice: "INT(11)", + time: "DATETIME", + amt: "INT(11)" + }); +} diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0006_eepnet_signups_3.js b/trunk/etherpad/src/etherpad/db_migrations/m0006_eepnet_signups_3.js new file mode 100644 index 0000000..349b27a --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0006_eepnet_signups_3.js @@ -0,0 +1,29 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.utils.isPrivateNetworkEdition"); +import("sqlbase.sqlobj"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + // add new columns. + sqlobj.addColumns('eepnet_signups', { + industry: 'VARCHAR(128)', + }); +} diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0007_create_pro_tables_v4.js b/trunk/etherpad/src/etherpad/db_migrations/m0007_create_pro_tables_v4.js new file mode 100644 index 0000000..bda5853 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0007_create_pro_tables_v4.js @@ -0,0 +1,67 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); + +function run() { + ['pro_domains', 'pro_users', 'pro_padmeta'].forEach(function(t) { + if (sqlcommon.doesTableExist(t)) { + sqlobj.dropTable(t); + } + }); + + sqlobj.createTable('pro_domains', { + id: sqlobj.getIdColspec(), + subDomain: 'VARCHAR(128) UNIQUE NOT NULL', + extDomain: 'VARCHAR(128) DEFAULT NULL', + orgName: 'VARCHAR(128)' + }); + + sqlobj.createIndex('pro_domains', ['subDomain']); + sqlobj.createIndex('pro_domains', ['extDomain']); + + sqlobj.createTable('pro_users', { + id: sqlobj.getIdColspec(), + domainId: 'INT NOT NULL', + fullName: 'VARCHAR(128) NOT NULL', + email: 'VARCHAR(128) NOT NULL', // not unique because same + // email can be on multiple domains. + passwordHash: 'VARCHAR(128) NOT NULL', + createdDate: sqlobj.getDateColspec("NOT NULL"), + lastLoginDate: sqlobj.getDateColspec("DEFAULT NULL"), + isAdmin: sqlobj.getBoolColspec("DEFAULT 0") + }); + + sqlobj.createTable('pro_padmeta', { + id: sqlobj.getIdColspec(), + domainId: 'INT NOT NULL', + localPadId: 'VARCHAR(128) NOT NULL', + title: 'VARCHAR(128)', + creatorId: 'INT DEFAULT NULL', + createdDate: sqlobj.getDateColspec("NOT NULL"), + lastEditorId: 'INT DEFAULT NULL', + lastEditedDate: sqlobj.getDateColspec("DEFAULT NULL") + }); + + sqlobj.createIndex('pro_padmeta', ['domainId', 'localPadId']); + + var pneDomain = "<<private-network>>"; + if (!sqlobj.selectSingle('pro_domains', {subDomain: pneDomain})) { + sqlobj.insert('pro_domains', {subDomain: pneDomain}); + } +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0008_persistent_vars.js b/trunk/etherpad/src/etherpad/db_migrations/m0008_persistent_vars.js new file mode 100644 index 0000000..30e379a --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0008_persistent_vars.js @@ -0,0 +1,31 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); + +function run() { + + var idColspec = 'INT NOT NULL '+sqlcommon.autoIncrementClause()+' PRIMARY KEY'; + + sqlobj.createTable('persistent_vars', { + id: idColspec, + name: 'VARCHAR(128) UNIQUE NOT NULL', + stringVal: 'VARCHAR(1024)' + }); + +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0009_pad_tables.js b/trunk/etherpad/src/etherpad/db_migrations/m0009_pad_tables.js new file mode 100644 index 0000000..93f5a62 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0009_pad_tables.js @@ -0,0 +1,31 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlbase"); + +function run() { + + // These table creations used to be in etherpad.pad.model.onStartup, but + // they make more sense here because later migrations access these tables. + sqlbase.createJSONTable("PAD_META"); + sqlbase.createJSONTable("PAD_APOOL"); + sqlbase.createStringArrayTable("PAD_REVS"); + sqlbase.createStringArrayTable("PAD_CHAT"); + sqlbase.createStringArrayTable("PAD_REVMETA"); + sqlbase.createStringArrayTable("PAD_AUTHORS"); + +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0010_pad_sqlmeta.js b/trunk/etherpad/src/etherpad/db_migrations/m0010_pad_sqlmeta.js new file mode 100644 index 0000000..36150b1 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0010_pad_sqlmeta.js @@ -0,0 +1,71 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlbase"); +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon"); +import("etherpad.utils.startConsoleProgressBar"); + + +function run() { + + sqlobj.dropAndCreateTable('PAD_SQLMETA', { + id: 'VARCHAR(128) PRIMARY KEY NOT NULL', + version: 'INT NOT NULL', + creationTime: sqlobj.getDateColspec('NOT NULL'), + lastWriteTime: sqlobj.getDateColspec('NOT NULL'), + headRev: 'INT NOT NULL' + }); + + sqlobj.createIndex('PAD_SQLMETA', ['version']); + + var allPadIds = sqlbase.getAllJSONKeys("PAD_META"); + + // If this is a new database, there are no pads; else + // it is an old database with version 1 pads. + if (allPadIds.length == 0) { + return; + } + + var numPadsTotal = allPadIds.length; + var numPadsSoFar = 0; + var progressBar = startConsoleProgressBar(); + + allPadIds.forEach(function(padId) { + var meta = sqlbase.getJSON("PAD_META", padId); + + sqlobj.insert("PAD_SQLMETA", { + id: padId, + version: 1, + creationTime: new Date(meta.creationTime || 0), + lastWriteTime: new Date(), + headRev: meta.head + }); + + delete meta.creationTime; // now stored in SQLMETA + delete meta.version; // just in case (was used during development) + delete meta.dirty; // no longer stored in DB + delete meta.lastAccess; // no longer stored in DB + + sqlbase.putJSON("PAD_META", padId, meta); + + numPadsSoFar++; + progressBar.update(numPadsSoFar/numPadsTotal, numPadsSoFar+"/"+numPadsTotal+" pads"); + }); + + progressBar.finish(); +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0011_pro_users_temppass.js b/trunk/etherpad/src/etherpad/db_migrations/m0011_pro_users_temppass.js new file mode 100644 index 0000000..5ac8b26 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0011_pro_users_temppass.js @@ -0,0 +1,33 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); + +function run() { + // allow null values in passwordHash + if (sqlcommon.isDerby()) { + sqlobj.alterColumn('pro_users', 'passwordHash', 'NULL'); + } else { + sqlobj.modifyColumn('pro_users', 'passwordHash', 'VARCHAR(128)'); + } + sqlobj.addColumns('pro_users', { + tempPassHash: 'VARCHAR(128)' + }); +} + + + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0012_pro_users_auto_signin.js b/trunk/etherpad/src/etherpad/db_migrations/m0012_pro_users_auto_signin.js new file mode 100644 index 0000000..ddd4cf6 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0012_pro_users_auto_signin.js @@ -0,0 +1,30 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); + +function run() { + sqlobj.createTable('pro_users_auto_signin', { + id: sqlobj.getIdColspec(), + cookie: 'VARCHAR(128) UNIQUE NOT NULL', + userId: 'INT UNIQUE NOT NULL', + expires: sqlobj.getDateColspec('NOT NULL') + }); + sqlobj.createIndex('pro_users_auto_signin', ['cookie']); + sqlobj.createIndex('pro_users_auto_signin', ['userId']); +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0013_pne_padv2_upgrade.js b/trunk/etherpad/src/etherpad/db_migrations/m0013_pne_padv2_upgrade.js new file mode 100644 index 0000000..146923a --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0013_pne_padv2_upgrade.js @@ -0,0 +1,54 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.utils.startConsoleProgressBar"); +import("etherpad.pad.easysync2migration"); +import("etherpad.pne.pne_utils"); +import("sqlbase.sqlobj"); +import("etherpad.log"); + +function run() { + + // this is a PNE-only migration + if (! pne_utils.isPNE()) { + return; + } + + var migrationsNeeded = sqlobj.selectMulti("PAD_SQLMETA", {version: 1}); + + if (migrationsNeeded.length == 0) { + return; + } + + var migrationsTotal = migrationsNeeded.length; + var migrationsSoFar = 0; + var progressBar = startConsoleProgressBar(); + + migrationsNeeded.forEach(function(obj) { + var padId = String(obj.id); + + log.info("Migrating pad "+padId+" from version 1 to version 2..."); + easysync2migration.migratePad(padId); + sqlobj.update("PAD_SQLMETA", {id: padId}, {version: 2}); + log.info("Migrated pad "+padId+"."); + + migrationsSoFar++; + progressBar.update(migrationsSoFar/migrationsTotal, migrationsSoFar+"/"+migrationsTotal+" pads"); + }); + + progressBar.finish(); +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0014_pne_globalpadids.js b/trunk/etherpad/src/etherpad/db_migrations/m0014_pne_globalpadids.js new file mode 100644 index 0000000..445b32d --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0014_pne_globalpadids.js @@ -0,0 +1,102 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.utils.startConsoleProgressBar"); +import("etherpad.pne.pne_utils"); +import("sqlbase.sqlobj"); +import("sqlbase.sqlbase"); +import("etherpad.log"); +import("sqlbase.sqlcommon.*"); +import("etherpad.pad.padutils"); + +function run() { + + // this is a PNE-only migration + if (! pne_utils.isPNE()) { + return; + } + + var renamesNeeded = sqlobj.selectMulti("PAD_SQLMETA", {}); + + if (renamesNeeded.length == 0) { + return; + } + + var renamesTotal = renamesNeeded.length; + var renamesSoFar = 0; + var progressBar = startConsoleProgressBar(); + + renamesNeeded.forEach(function(obj) { + var oldPadId = String(obj.id); + var newPadId; + if (/^1\$[a-zA-Z0-9\-]+$/.test(oldPadId)) { + // not expecting a user pad beginning with "1$"; + // this case is to avoid trashing dev databases + newPadId = oldPadId; + } + else { + var localPadId = padutils.makeValidLocalPadId(oldPadId); + newPadId = "1$"+localPadId; + + // PAD_SQLMETA + obj.id = newPadId; + sqlobj.deleteRows("PAD_SQLMETA", {id:oldPadId}); + sqlobj.insert("PAD_SQLMETA", obj); + + // PAD_META + var meta = sqlbase.getJSON("PAD_META", oldPadId); + meta.padId = newPadId; + sqlbase.deleteJSON("PAD_META", oldPadId); + sqlbase.putJSON("PAD_META", newPadId, meta); + + // PAD_APOOL + var apool = sqlbase.getJSON("PAD_APOOL", oldPadId); + sqlbase.deleteJSON("PAD_APOOL", oldPadId); + sqlbase.putJSON("PAD_APOOL", newPadId, apool); + + function renamePadInStringArrayTable(arrayName) { + var stmnt = "UPDATE "+btquote("PAD_"+arrayName.toUpperCase()+"_META")+ + " SET "+btquote("ID")+" = ? WHERE "+btquote("ID")+" = ?"; + return withConnection(function(conn) { + var pstmnt = conn.prepareStatement(stmnt); + return closing(pstmnt, function() { + pstmnt.setString(1, newPadId); + pstmnt.setString(2, oldPadId); + pstmnt.executeUpdate(); + }); + }); + } + + renamePadInStringArrayTable("revs"); + renamePadInStringArrayTable("chat"); + renamePadInStringArrayTable("revmeta"); + renamePadInStringArrayTable("authors"); + + sqlobj.insert('pro_padmeta', { + localPadId: localPadId, + title: localPadId, + createdDate: obj.creationTime, + domainId: 1 // PNE + }); + } + + renamesSoFar++; + progressBar.update(renamesSoFar/renamesTotal, renamesSoFar+"/"+renamesTotal+" pads"); + }); + + progressBar.finish(); +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0015_padmeta_passwords.js b/trunk/etherpad/src/etherpad/db_migrations/m0015_padmeta_passwords.js new file mode 100644 index 0000000..8fa98bb --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0015_padmeta_passwords.js @@ -0,0 +1,25 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); + +function run() { + sqlobj.addColumns('pro_padmeta', { + password: 'VARCHAR(128) DEFAULT NULL' + }); +} + + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0016_pne_tracking_data.js b/trunk/etherpad/src/etherpad/db_migrations/m0016_pne_tracking_data.js new file mode 100644 index 0000000..abcc93f --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0016_pne_tracking_data.js @@ -0,0 +1,35 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("etherpad.utils.isPrivateNetworkEdition"); + + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + sqlobj.createTable('pne_tracking_data', { + id: sqlobj.getIdColspec(), + date: sqlobj.getDateColspec("NOT NULL"), + keyHash: 'VARCHAR(128) DEFAULT NULL', + name: 'VARCHAR(128) NOT NULL', + value: 'VARCHAR(1024) NOT NULL' + }); +} + + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0017_pne_tracking_data_v2.js b/trunk/etherpad/src/etherpad/db_migrations/m0017_pne_tracking_data_v2.js new file mode 100644 index 0000000..1067840 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0017_pne_tracking_data_v2.js @@ -0,0 +1,30 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("etherpad.utils.isPrivateNetworkEdition"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + sqlobj.addColumns('pne_tracking_data', { + remoteIp: 'VARCHAR(128) NOT NULL' + }); +} + + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0018_eepnet_checkout_tables.js b/trunk/etherpad/src/etherpad/db_migrations/m0018_eepnet_checkout_tables.js new file mode 100644 index 0000000..6e10000 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0018_eepnet_checkout_tables.js @@ -0,0 +1,82 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.utils.isPrivateNetworkEdition"); +import("sqlbase.sqlobj"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + var idColspec = "INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY"; + + sqlobj.createTable('checkout_purchase', { + id: idColspec, + invoiceId: "INT NOT NULL", + owner: "VARCHAR(128) NOT NULL", + email: "VARCHAR(128) NOT NULL", + organization: "VARCHAR(128) NOT NULL", + firstName: "VARCHAR(100) NOT NULL", + lastName: "VARCHAR(100) NOT NULL", + addressLine1: "VARCHAR(100) NOT NULL", + addressLine2: "VARCHAR(100) NOT NULL", + city: "VARCHAR(40) NOT NULL", + state: "VARCHAR(2) NOT NULL", + zip: "VARCHAR(10) NOT NULL", + numUsers: "INT NOT NULL", + date: "TIMESTAMP NOT NULL", + cents: "INT NOT NULL", + referral: "VARCHAR(8)", + receiptEmail: "TEXT", + purchaseType: "ENUM('creditcard', 'invoice', 'paypal') NOT NULL", + licenseKey: "VARCHAR(1024)" + }, { + email: true, + invoiceId: true + }); + + sqlobj.createTable('checkout_referral', { + id: "VARCHAR(8) NOT NULL PRIMARY KEY", + productPctDiscount: "INT", + supportPctDiscount: "INT", + totalPctDiscount: "INT", + freeUsersCount: "INT", + freeUsersPct: "INT" + }); + + // add a sample referral code. + sqlobj.insert('checkout_referral', { + id: 'EPCO6128', + productPctDiscount: 50, + supportPctDiscount: 25, + totalPctDiscount: 15, + freeUsersCount: 20, + freeUsersPct: 10 + }); + + // add a "free" referral code. + sqlobj.insert('checkout_referral', { + id: 'EP99FREE', + totalPctDiscount: 99 + }); + + sqlobj.insert('checkout_referral', { + id: 'EPFREE68', + totalPctDiscount: 100 + }); + +} diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0019_padmeta_deleted.js b/trunk/etherpad/src/etherpad/db_migrations/m0019_padmeta_deleted.js new file mode 100644 index 0000000..1f9ecbb --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0019_padmeta_deleted.js @@ -0,0 +1,24 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); + +function run() { + sqlobj.addColumns('pro_padmeta', { + isDeleted: sqlobj.getBoolColspec("NOT NULL DEFAULT 0") + }); +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0020_padmeta_archived.js b/trunk/etherpad/src/etherpad/db_migrations/m0020_padmeta_archived.js new file mode 100644 index 0000000..a776622 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0020_padmeta_archived.js @@ -0,0 +1,25 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); + +function run() { + sqlobj.addColumns('pro_padmeta', { + isArchived: sqlobj.getBoolColspec("NOT NULL DEFAULT 0") + }); +} + + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0021_pro_padmeta_json.js b/trunk/etherpad/src/etherpad/db_migrations/m0021_pro_padmeta_json.js new file mode 100644 index 0000000..9f357b7 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0021_pro_padmeta_json.js @@ -0,0 +1,57 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("fastJSON"); +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon"); + +function run() { + sqlobj.addColumns('pro_padmeta', { + proAttrsJson: sqlobj.getLongtextColspec("") + }); + + // convert all existing columns into metaJSON + + sqlcommon.inTransaction(function() { + var records = sqlobj.selectMulti('pro_padmeta', {}, {}); + records.forEach(function(r) { + migrateRecord(r); + }); + }); +} + +function migrateRecord(r) { + var editors = []; + if (r.creatorId) { + editors.push(r.creatorId); + } + if (r.lastEditorId) { + if (editors.indexOf(r.lastEditorId) < 0) { + editors.push(r.lastEditorId); + } + } + editors.sort(); + + var proAttrs = { + editors: editors, + }; + + var proAttrsJson = fastJSON.stringify(proAttrs); + + sqlobj.update('pro_padmeta', {id: r.id}, {proAttrsJson: proAttrsJson}); +} + + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0022_create_userids_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0022_create_userids_table.js new file mode 100644 index 0000000..23ca8d3 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0022_create_userids_table.js @@ -0,0 +1,30 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.utils.isPrivateNetworkEdition"); +import("sqlbase.sqlobj"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + sqlobj.createTable('pad_cookie_userids', { + id: "VARCHAR(40) NOT NULL PRIMARY KEY", + createdDate: sqlobj.getDateColspec("NOT NULL"), + lastActiveDate: sqlobj.getDateColspec("NOT NULL") + }); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0023_create_usagestats_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0023_create_usagestats_table.js new file mode 100644 index 0000000..927cdc9 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0023_create_usagestats_table.js @@ -0,0 +1,32 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.utils.isPrivateNetworkEdition"); +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + sqlobj.createTable('usage_stats', { + id: 'INT NOT NULL '+sqlcommon.autoIncrementClause()+' PRIMARY KEY', + name: 'VARCHAR(128) NOT NULL', + timestamp: 'INT NOT NULL', + value: 'INT NOT NULL' + }); +} diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0024_statistics_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0024_statistics_table.js new file mode 100644 index 0000000..9d6e58c --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0024_statistics_table.js @@ -0,0 +1,42 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.utils.isPrivateNetworkEdition"); +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon"); +import("fastJSON"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + sqlobj.createTable('statistics', { + id: 'INT NOT NULL '+sqlcommon.autoIncrementClause()+' PRIMARY KEY', + name: 'VARCHAR(128) NOT NULL', + timestamp: 'INT NOT NULL', + value: 'TEXT NOT NULL' + }); + + var oldStats = sqlobj.selectMulti('usage_stats', {}); + oldStats.forEach(function(stat) { + sqlobj.insert('statistics', { + timestamp: stat.timestamp, + name: stat.name, + value: fastJSON.stringify({value: stat.value}) + }); + }); +} diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0025_rename_pro_users_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0025_rename_pro_users_table.js new file mode 100644 index 0000000..a429f41 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0025_rename_pro_users_table.js @@ -0,0 +1,26 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon"); + +function run() { + sqlobj.renameTable('pro_users', 'pro_accounts'); + sqlobj.renameTable('pro_users_auto_signin', 'pro_accounts_auto_signin'); + sqlobj.changeColumn('pro_accounts_auto_signin', 'userId', 'accountId INT UNIQUE NOT NULL'); + sqlobj.createIndex('pro_accounts_auto_signin', ['accountId']); +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0026_create_guests_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0026_create_guests_table.js new file mode 100644 index 0000000..7c41309 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0026_create_guests_table.js @@ -0,0 +1,37 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); + +function run() { + if (sqlcommon.doesTableExist("pad_guests")) { + sqlobj.dropTable("pad_guests"); + } + + sqlobj.createTable('pad_guests', { + id: sqlobj.getIdColspec(), + privateKey: 'VARCHAR(63) UNIQUE NOT NULL', + userId: 'VARCHAR(63) UNIQUE NOT NULL', + createdDate: sqlobj.getDateColspec("NOT NULL"), + lastActiveDate: sqlobj.getDateColspec("NOT NULL"), + data: sqlobj.getLongtextColspec("") + }); + + sqlobj.createIndex('pad_guests', ['privateKey']); + sqlobj.createIndex('pad_guests', ['userId']); +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0027_pro_config.js b/trunk/etherpad/src/etherpad/db_migrations/m0027_pro_config.js new file mode 100644 index 0000000..9cbb629 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0027_pro_config.js @@ -0,0 +1,27 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); + +function run() { + sqlobj.createTable('pro_config', { + id: sqlobj.getIdColspec(), + domainId: 'INT', + name: 'VARCHAR(128)', + jsonVal: sqlobj.getLongtextColspec("") + }); +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0028_ondemand_beta_emails.js b/trunk/etherpad/src/etherpad/db_migrations/m0028_ondemand_beta_emails.js new file mode 100644 index 0000000..f708363 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0028_ondemand_beta_emails.js @@ -0,0 +1,29 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); + +function run() { + sqlobj.createTable('pro_beta_signups', { + id: sqlobj.getIdColspec(), + email: 'VARCHAR(256)', + activationCode: 'VARCHAR(128)', + isActivated: sqlobj.getBoolColspec(), + signupDate: sqlobj.getDateColspec(), + activationDate: sqlobj.getDateColspec() + }); +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0029_lowercase_subdomains.js b/trunk/etherpad/src/etherpad/db_migrations/m0029_lowercase_subdomains.js new file mode 100644 index 0000000..36b76ab --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0029_lowercase_subdomains.js @@ -0,0 +1,31 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); + +function run() { + var recordList = sqlobj.selectMulti('pro_domains', {}); + recordList.forEach(function(r) { + var subDomain = r.subDomain; + if (subDomain != subDomain.toLowerCase()) { + // delete this domain record and all accounts associated with it. + sqlobj.deleteRows('pro_domains', {id: r.id}); + sqlobj.deleteRows('pro_accounts', {domainId: r.id}); + } + }); +} + + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0030_fix_statistics_values.js b/trunk/etherpad/src/etherpad/db_migrations/m0030_fix_statistics_values.js new file mode 100644 index 0000000..aeaa40f --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0030_fix_statistics_values.js @@ -0,0 +1,26 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("etherpad.utils.isPrivateNetworkEdition"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + sqlobj.modifyColumn('statistics', 'value', 'MEDIUMTEXT NOT NULL'); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0031_deleted_pro_users.js b/trunk/etherpad/src/etherpad/db_migrations/m0031_deleted_pro_users.js new file mode 100644 index 0000000..b9744a3 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0031_deleted_pro_users.js @@ -0,0 +1,24 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); + +function run() { + sqlobj.addColumns('pro_accounts', { + isDeleted: sqlobj.getBoolColspec("NOT NULL DEFAULT 0") + }); +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0032_reduce_topvalues_counts.js b/trunk/etherpad/src/etherpad/db_migrations/m0032_reduce_topvalues_counts.js new file mode 100644 index 0000000..5e748f5 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0032_reduce_topvalues_counts.js @@ -0,0 +1,39 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("etherpad.utils.isPrivateNetworkEdition"); +import("fastJSON"); + +import("etherpad.statistics.statistics"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + statistics.getAllStatNames().forEach(function(statName) { + if (statistics.getStatData(statName).dataType == 'topValues') { + var entries = sqlobj.selectMulti('statistics', {name: statName}); + entries.forEach(function(statEntry) { + var value = fastJSON.parse(statEntry.value); + value.topValues = value.topValues.slice(0, 50); + statEntry.value = fastJSON.stringify(value); + sqlobj.update('statistics', {id: statEntry.id}, statEntry); + }); + } + }); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0033_pro_account_usage.js b/trunk/etherpad/src/etherpad/db_migrations/m0033_pro_account_usage.js new file mode 100644 index 0000000..4b33f52 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0033_pro_account_usage.js @@ -0,0 +1,30 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); + +function run() { + sqlobj.createTable('pro_account_usage', { + id: sqlobj.getIdColspec(), + domainId: 'INT NOT NULL UNIQUE', + count: 'INT NOT NULL DEFAULT 0', + lastReset: sqlobj.getDateColspec(), + lastUpdated: sqlobj.getDateColspec() + }); + sqlobj.createIndex('pro_account_usage', ['domainId']); +} + + diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0034_create_recurring_billing_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0034_create_recurring_billing_table.js new file mode 100644 index 0000000..491581b --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0034_create_recurring_billing_table.js @@ -0,0 +1,42 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.utils.isPrivateNetworkEdition"); +import("sqlbase.sqlobj"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + var idColspec = "INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY"; + + sqlobj.createTable('billing_payment_info', { + customer: "INT(11) NOT NULL PRIMARY KEY", + fullname: "VARCHAR(128)", + paymentsummary: "VARCHAR(128)", + expiration: "VARCHAR(6)", // MMYYYY + transaction: "VARCHAR(128)" + }); + + sqlobj.addColumns('billing_purchase', { + error: "TEXT" + }); + + sqlobj.addColumns('billing_invoice', { + users: "INT(11)" + }) +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0035_add_email_to_paymentinfo.js b/trunk/etherpad/src/etherpad/db_migrations/m0035_add_email_to_paymentinfo.js new file mode 100644 index 0000000..a49e9f9 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0035_add_email_to_paymentinfo.js @@ -0,0 +1,28 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.utils.isPrivateNetworkEdition"); +import("sqlbase.sqlobj"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + sqlobj.addColumns('billing_payment_info', { + email: "VARCHAR(255)" + }); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0036_create_missing_subscription_records.js b/trunk/etherpad/src/etherpad/db_migrations/m0036_create_missing_subscription_records.js new file mode 100644 index 0000000..ce77734 --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0036_create_missing_subscription_records.js @@ -0,0 +1,45 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("dateutils"); +import("etherpad.utils.isPrivateNetworkEdition"); +import("sqlbase.sqlobj"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + var allDomains = sqlobj.selectMulti('pro_domains', {}, {}); + + allDomains.forEach(function(domain) { + var domainId = domain.id; + var accounts = sqlobj.selectMulti('pro_accounts', {domainId: domainId}, {}); + if (accounts.length > 3) { + if (! sqlobj.selectSingle('billing_purchase', {product: "ONDEMAND", customer: domainId}, {})) { + sqlobj.insert('billing_purchase', { + product: "ONDEMAND", + paidThrough: dateutils.noon(new Date(Date.now()-1000*86400)), + type: 'subscription', + customer: domainId, + status: 'inactive', + cost: 0, + coupon: "" + }); + } + } + }); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0037_create_pro_referral_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0037_create_pro_referral_table.js new file mode 100644 index 0000000..7a9982c --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0037_create_pro_referral_table.js @@ -0,0 +1,32 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.utils.isPrivateNetworkEdition"); +import("sqlbase.sqlobj"); + +function run() { + if (isPrivateNetworkEdition()) { + return; + } + + var idColspec = "INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY"; + + sqlobj.createTable('checkout_pro_referral', { + id: "VARCHAR(8) NOT NULL PRIMARY KEY", + pctDiscount: "INT", + freeUsers: "INT", + }); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0038_pad_coarse_revs.js b/trunk/etherpad/src/etherpad/db_migrations/m0038_pad_coarse_revs.js new file mode 100644 index 0000000..1e9a53c --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/m0038_pad_coarse_revs.js @@ -0,0 +1,26 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlbase"); + +function run() { + + sqlbase.createStringArrayTable("PAD_REVS10"); + sqlbase.createStringArrayTable("PAD_REVS100"); + sqlbase.createStringArrayTable("PAD_REVS1000"); + +} + diff --git a/trunk/etherpad/src/etherpad/db_migrations/migration_runner.js b/trunk/etherpad/src/etherpad/db_migrations/migration_runner.js new file mode 100644 index 0000000..ddf201d --- /dev/null +++ b/trunk/etherpad/src/etherpad/db_migrations/migration_runner.js @@ -0,0 +1,147 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Database migrations. + +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); + +import("etherpad.globals.*"); +import("etherpad.utils.*"); +import("etherpad.log"); +import("etherpad.pne.pne_utils"); + +jimport("java.lang.System.out.println"); + +//---------------------------------------------------------------- +// 1 migration per file +//---------------------------------------------------------------- + +var migrations = [ + "m0000_test", + "m0001_eepnet_signups_init", + "m0002_eepnet_signups_2", + "m0003_create_tests_table_v2", + "m0004_convert_all_tables_to_innodb", + "m0005_create_billing_tables", + "m0006_eepnet_signups_3", + "m0007_create_pro_tables_v4", + "m0008_persistent_vars", + "m0009_pad_tables", + "m0010_pad_sqlmeta", + "m0011_pro_users_temppass", + "m0012_pro_users_auto_signin", + "m0013_pne_padv2_upgrade", + "m0014_pne_globalpadids", + "m0015_padmeta_passwords", + "m0016_pne_tracking_data", + "m0017_pne_tracking_data_v2", + "m0018_eepnet_checkout_tables", + "m0019_padmeta_deleted", + "m0020_padmeta_archived", + "m0021_pro_padmeta_json", + "m0022_create_userids_table", + "m0023_create_usagestats_table", + "m0024_statistics_table", + "m0025_rename_pro_users_table", + "m0026_create_guests_table", + "m0027_pro_config", + "m0028_ondemand_beta_emails", + "m0029_lowercase_subdomains", + "m0030_fix_statistics_values", + "m0031_deleted_pro_users", + "m0032_reduce_topvalues_counts", + "m0033_pro_account_usage", + "m0034_create_recurring_billing_table", + "m0035_add_email_to_paymentinfo", + "m0036_create_missing_subscription_records", + "m0037_create_pro_referral_table", + "m0038_pad_coarse_revs" +]; + +var mscope = this; +migrations.forEach(function(m) { + import.call(mscope, "etherpad.db_migrations."+m); +}); + +//---------------------------------------------------------------- + +function dmesg(m) { + if ((!isProduction()) || appjet.cache.db_migrations_print_debug) { + log.info(m); + println(m); + } +} + +function onStartup() { + appjet.cache.db_migrations_print_debug = true; + if (!sqlcommon.doesTableExist("db_migrations")) { + appjet.cache.db_migrations_print_debug = false; + sqlobj.createTable('db_migrations', { + id: 'INT NOT NULL '+sqlcommon.autoIncrementClause()+' PRIMARY KEY', + name: 'VARCHAR(255) NOT NULL UNIQUE', + completed: 'TIMESTAMP' + }); + } + + if (pne_utils.isPNE()) { pne_utils.checkDbVersionUpgrade(); } + runMigrations(); + if (pne_utils.isPNE()) { pne_utils.saveDbVersion(); } +} + +function _migrationName(m) { + m = m.replace(/^m\d+\_/, ''); + m = m.replace(/\_/g, '-'); + return m; +} + +function getCompletedMigrations() { + var completedMigrationsList = sqlobj.selectMulti('db_migrations', {}, {}); + var completedMigrations = {}; + + completedMigrationsList.forEach(function(c) { + completedMigrations[c.name] = true; + }); + + return completedMigrations; +} + +function runMigrations() { + var completedMigrations = getCompletedMigrations(); + + dmesg("Checking for database migrations..."); + migrations.forEach(function(m) { + var name = _migrationName(m); + if (!completedMigrations[name]) { + sqlcommon.inTransaction(function() { + dmesg("performing database migration: ["+name+"]"); + var startTime = +(new Date); + + mscope[m].run(); + + var elapsedMs = +(new Date) - startTime; + dmesg("migration completed in "+elapsedMs+"ms"); + + sqlobj.insert('db_migrations', { + name: name, + completed: new Date() + }); + }); + } + }); +} + + diff --git a/trunk/etherpad/src/etherpad/debug.js b/trunk/etherpad/src/etherpad/debug.js new file mode 100644 index 0000000..069ad14 --- /dev/null +++ b/trunk/etherpad/src/etherpad/debug.js @@ -0,0 +1,26 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.globals.*"); + +jimport("java.lang.System.out.println"); + +function dmesg(m) { + if (!isProduction()) { + println(m); + } +} + diff --git a/trunk/etherpad/src/etherpad/globals.js b/trunk/etherpad/src/etherpad/globals.js new file mode 100644 index 0000000..2bae776 --- /dev/null +++ b/trunk/etherpad/src/etherpad/globals.js @@ -0,0 +1,41 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//---------------------------------------------------------------- +// global variabls +//---------------------------------------------------------------- + +var COMETPATH = "/comet"; + +var COLOR_PALETTE = ['#ffc6c6','#ffe2bf','#fffcbf','#cbffb3','#b3fff1','#c6e7ff','#dcccff','#ffd9fb']; + +function isProduction() { + return (appjet.config['etherpad.isProduction'] == "true"); +} + +var SUPERDOMAINS = { + 'localhost': true, + 'pad.spline.inf.fu-berlin.de': true, + 'pad.spline.de': true, + 'pad.spline.nomad': true +}; + +var PNE_RELEASE_VERSION = "1.1.3"; +var PNE_RELEASE_DATE = "June 15, 2009"; + +var PRO_FREE_ACCOUNTS = 1e9; + + diff --git a/trunk/etherpad/src/etherpad/helpers.js b/trunk/etherpad/src/etherpad/helpers.js new file mode 100644 index 0000000..cafa201 --- /dev/null +++ b/trunk/etherpad/src/etherpad/helpers.js @@ -0,0 +1,276 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("fastJSON"); +import("jsutils.eachProperty"); +import("faststatic"); +import("comet"); +import("funhtml.META"); + +import("etherpad.globals.*"); +import("etherpad.debug.dmesg"); + +import("etherpad.pro.pro_utils"); + +jimport("java.lang.System.out.println"); + +//---------------------------------------------------------------- +// array that supports contains() in O(1) + +var _UniqueArray = function() { + this._a = []; + this._m = {}; +}; +_UniqueArray.prototype.add = function(x) { + if (!this._m[x]) { + this._a.push(x); + this._m[x] = true; + } +}; +_UniqueArray.prototype.asArray = function() { + return this._a; +}; + +//---------------------------------------------------------------- +// EJS template helpers +//---------------------------------------------------------------- + +function _hd() { + if (!appjet.requestCache.helperData) { + appjet.requestCache.helperData = { + clientVars: {}, + htmlTitle: "", + headExtra: "", + bodyId: "", + bodyClasses: new _UniqueArray(), + cssIncludes: new _UniqueArray(), + jsIncludes: new _UniqueArray(), + includeCometJs: false, + suppressGA: false, + showHeader: true, + robotsPolicy: null + }; + } + return appjet.requestCache.helperData; +} + +function addBodyClass(c) { + _hd().bodyClasses.add(c); +} + +function addClientVars(vars) { + eachProperty(vars, function(k,v) { + _hd().clientVars[k] = v; + }); +} + +function addToHead(stuff) { + _hd().headExtra += stuff; +} + +function setHtmlTitle(t) { + _hd().htmlTitle = t; +} + +function setBodyId(id) { + _hd().bodyId = id; +} + +function includeJs(relpath) { + _hd().jsIncludes.add(relpath); +} + +function includeJQuery() { + includeJs("jquery-1.3.2.js"); +} + +function includeCss(relpath) { + _hd().cssIncludes.add(relpath); +} + +function includeCometJs() { + _hd().includeCometJs = true; +} + +function suppressGA() { + _hd().suppressGA = true; +} + +function hideHeader() { + _hd().showHeader = false; +} + +//---------------------------------------------------------------- +// for rendering HTML +//---------------------------------------------------------------- + +function bodyClasses() { + return _hd().bodyClasses.asArray().join(' '); +} + +function clientVarsScript() { + var x = _hd().clientVars; + x = fastJSON.stringify(x); + if (x == '{}') { + return '<!-- no client vars -->'; + } + x = x.replace(/</g, '\\x3c'); + return [ + '<script type="text/javascript">', + ' // <![CDATA[', + 'var clientVars = '+x+';', + ' // ]]>', + '</script>' + ].join('\n'); +} + +function htmlTitle() { + return _hd().htmlTitle; +} + +function bodyId() { + return _hd().bodyId; +} + +function baseHref() { + return request.scheme + "://"+ request.host + "/"; +} + +function headExtra() { + return _hd().headExtra; +} + +function jsIncludes() { + if (isProduction()) { + var jsincludes = _hd().jsIncludes.asArray(); + if (_hd().includeCometJs) { + jsincludes.splice(0, 0, { + getPath: function() { return 'comet-client.js'; }, + getContents: function() { return comet.clientCode(); }, + getMTime: function() { return comet.clientMTime(); } + }); + } + if (jsincludes.length < 1) { return ''; } + var key = faststatic.getCompressedFilesKey('js', '/static/js', jsincludes); + return '<script type="text/javascript" src="/static/compressed/'+key+'"></script>'; + } else { + var ts = +(new Date); + var r = []; + if (_hd().includeCometJs) { + r.push('<script type="text/javascript" src="'+COMETPATH+'/js/client.js?'+ts+'"></script>'); + } + _hd().jsIncludes.asArray().forEach(function(relpath) { + r.push('<script type="text/javascript" src="/static/js/'+relpath+'?'+ts+'"></script>'); + }); + return r.join('\n'); + } +} + +function cssIncludes() { + if (isProduction()) { + var key = faststatic.getCompressedFilesKey('css', '/static/css', _hd().cssIncludes.asArray()); + return '<link href="/static/compressed/'+key+'" rel="stylesheet" type="text/css" />'; + } else { + var ts = +(new Date); + var r = []; + _hd().cssIncludes.asArray().forEach(function(relpath) { + r.push('<link href="/static/css/'+relpath+'?'+ts+'" rel="stylesheet" type="text/css" />'); + }); + return r.join('\n'); + } +} + +function oemail(username) { + return '<<a class="obfuscemail" href="mailto:'+username+'@p*d.sp***e.inf.fu-berlin.de">'+ + username+'@p*d.sp***e.inf.fu-berlin.de</a>>'; +} + +function googleAnalytics() { + // GA disabled always now. + return ''; + + if (!isProduction()) { return ''; } + if (_hd().suppressGA) { return ''; } + return [ + '<script type="text/javascript">', + ' var gaJsHost = (("https:" == document.location.protocol) ? "https://ssl." : "http://www.");', + ' document.write(unescape("%3Cscript src=\'" + gaJsHost + "google-analytics.com/ga.js\' type=\'text/javascript\'%3E%3C/script%3E"));', + '</script>', + '<script type="text/javascript">', + 'try {', + ' var pageTracker = _gat._getTracker("UA-6236278-1");', + ' pageTracker._trackPageview();', + '} catch(err) {}</script>' + ].join('\n'); +} + +function isHeaderVisible() { + return _hd().showHeader; +} + +function setRobotsPolicy(policy) { + _hd().robotsPolicy = policy; +} +function robotsMeta() { + if (!_hd().robotsPolicy) { return ''; } + var content = ""; + content += (_hd().robotsPolicy.index ? 'INDEX' : 'NOINDEX'); + content += ", "; + content += (_hd().robotsPolicy.follow ? 'FOLLOW' : 'NOFOLLOW'); + return META({name: "ROBOTS", content: content}); +} + +function thawteSiteSeal() { + return [ + '<div>', + '<table width="10" border="0" cellspacing="0" align="center">', + '<tr>', + '<td>', + '<script src="https://siteseal.thawte.com/cgi/server/thawte_seal_generator.exe"></script>', + '</td>', + '</tr>', + '<tr>', + '<td height="0" align="center">', + '<a style="color:#AD0034" target="_new"', + 'href="http://www.thawte.com/digital-certificates/">', + '<span style="font-family:arial; font-size:8px; color:#AD0034">', + 'ABOUT SSL CERTIFICATES</span>', + '</a>', + '</td>', + '</tr>', + '</table>', + '</div>' + ].join('\n'); +} + +function clearFloats() { + return '<div style="clear: both;"><!-- --></div>'; +} + +function rafterBlogUrl() { + return '/ep/blog/posts/google-acquires-appjet'; +} + +function rafterNote() { + return """<div style='border: 1px solid #ccc; background: #fee; padding: 1em; margin: 1em 0;'> + <b>Note: </b>We are no longer accepting new accounts. <a href='"""+rafterBlogUrl()+"""'>Read more</a>. + </div>"""; +} + +function rafterTerminationDate() { + return "March 31, 2010"; +} + diff --git a/trunk/etherpad/src/etherpad/importexport/importexport.js b/trunk/etherpad/src/etherpad/importexport/importexport.js new file mode 100644 index 0000000..304a1f4 --- /dev/null +++ b/trunk/etherpad/src/etherpad/importexport/importexport.js @@ -0,0 +1,241 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +jimport("java.io.File"); +jimport("java.io.FileOutputStream"); +jimport("java.lang.System.out.println"); +jimport("java.io.ByteArrayInputStream"); +jimport("java.io.ByteArrayOutputStream"); +jimport("java.io.DataInputStream"); +jimport("java.io.DataOutputStream"); +jimport("net.appjet.common.sars.SarsClient"); +jimport("com.etherpad.openofficeservice.OpenOfficeService"); +jimport("com.etherpad.openofficeservice.UnsupportedFormatException"); +jimport("com.etherpad.openofficeservice.TemporaryFailure"); + +import("etherpad.log"); +import("etherpad.utils"); +import("sync"); +import("execution"); +import("varz"); +import("exceptionutils"); + +function _log(obj) { + log.custom("import-export", obj); +} + +function onStartup() { + execution.initTaskThreadPool("importexport", 1); +} + +var formats = { + pdf: 'application/pdf', + doc: 'application/msword', + html: 'text/html; charset=utf-8', + odt: 'application/vnd.oasis.opendocument.text', + txt: 'text/plain; charset=utf-8' +} + +function _createTempFile(bytes, type) { + var f = File.createTempFile("ooconvert-", (type === null ? null : (type == "" ? "" : "."+type))); + if (bytes) { + var fos = new FileOutputStream(f); + fos.write(bytes); + } + return f; +} + +function _initConverterClient(convertServer) { + if (convertServer) { + var convertHost = convertServer.split(":")[0]; + var convertPort = Number(convertServer.split(":")[1]); + if (! appjet.scopeCache.converter) { + var converter = new SarsClient("ooffice-password", convertHost, convertPort); + appjet.scopeCache.converter = converter; + converter.setConnectTimeout(5000); + converter.setReadTimeout(40000); + appjet.scopeCache.converter.connect(); + } + return appjet.scopeCache.converter; + } else { + return null; + } +} + +function _conversionSarsFailure() { + delete appjet.scopeCache.converter; +} + +function errorUnsupported(from) { + return "Unsupported file type"+(from ? ": <strong>"+from+"</strong>." : ".")+" Etherpad can only import <strong>txt</strong>, <strong>html</strong>, <strong>rtf</strong>, <strong>doc</strong>, and <strong>docx</strong> files."; +} +var errorTemporary = "A temporary failure occurred; please try again later."; + +function doSlowFileConversion(from, to, bytes, continuation) { + var bytes = convertFileSlowly(from, to, bytes); + continuation.resume(); + return bytes; +} + +function _convertOverNetwork(convertServer, from, to, bytes) { + var c = _initConverterClient(convertServer); + var reqBytes = new ByteArrayOutputStream(); + var req = new DataOutputStream(reqBytes); + req.writeUTF(from); + req.writeUTF(to); + req.writeInt(bytes.length); + req.write(bytes, 0, bytes.length); + + var retBtyes; + try { + retBytes = c.message(reqBytes.toByteArray()); + } catch (e) { + if (e.javaException) { + net.appjet.oui.exceptionlog.apply(e.javaException) + } + _conversionSarsFailure(); + return "A communications failure occurred; please try again later."; + } + + if (retBytes.length == 0) { + return "An unknown failure occurred; please try again later. (#5)"; + } + var res = new DataInputStream(new ByteArrayInputStream(retBytes)); + var status = res.readInt(); + if (status == 0) { // success + var len = res.readInt(); + var resBytes = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, len); + res.readFully(resBytes); + return resBytes; + } else if (status == 1) { + return errorTemporary; + } else if (status == 2) { + var permFailureCode = res.readInt(); + if (permFailureCode == 0) { + return "An unknown failure occurred. (#1)"; + } else if (permFailureCode == 1) { + return errorUnsupported(from); + } + } else { + return "An unknown failure occurred. (#2)"; + } +} + +function convertFileSlowly(from, to, bytes) { + var convertServer = appjet.config["etherpad.sofficeConversionServer"]; + if (convertServer) { + return _convertOverNetwork(convertServer, from, to, bytes); + } + + if (! utils.hasOffice()) { + return "EtherPad is not configured to import or export formats other than <strong>txt</strong> and <strong>html</strong>. Please contact your system administrator for details."; + } + OpenOfficeService.setExecutable(appjet.config["etherpad.soffice"]); + try { + return OpenOfficeService.convertFile(from, to, bytes); + } catch (e) { + if (e.javaException instanceof TemporaryFailure) { + return errorTemporary; + } else if (e.javaException instanceof UnsupportedFormatException) { + return errorUnsupported(from); + } else { + return "An unknown failure occurred. (#3)"; + } + } +} + +function _noteConversionAttempt() { + varz.incrementInt("importexport-conversions-attempted"); +} + +function _noteConversionSuccess() { + varz.incrementInt("importexport-conversions-successful"); +} + +function _noteConversionFailure() { + varz.incrementInt("importexport-conversions-failed"); +} + +function _noteConversionTimeout() { + varz.incrementInt("importexport-conversions-timeout"); +} + +function _noteConversionImpossible() { + varz.incrementInt("importexport-conversions-impossible"); +} + +function precomputedConversionResult(from, to, bytes) { + try { + var retBytes = request.cache.conversionCallable.get(500, java.util.concurrent.TimeUnit.MILLISECONDS); + var delay = Date.now() - request.cache.startTime; + _log({type: "conversion-latency", from: from, to: to, + numBytes: request.cache.conversionByteLength, + delay: delay}); + varz.addToInt("importexport-total-conversion-millis", delay); + if (typeof(retBytes) == 'string') { + _log({type: "error", error: "conversion-failed", from: from, to: to, + numBytes: request.cache.conversionByteLength, + delay: delay}); + _noteConversionFailure(); + } else { + _noteConversionSuccess(); + } + return retBytes; + } catch (e) { + if (e.javaException instanceof java.util.concurrent.TimeoutException) { + _noteConversionTimeout(); + request.cache.conversionCallable.cancel(false); + _log({type: "error", error: "conversion-failed", from: from, to: to, + numBytes: request.cache.conversionByteLength, + delay: -1}); + return "Conversion timed out. Please try again later."; + } + _log({type: "error", error: "conversion-failed", from: from, to: to, + numBytes: request.cache.conversionByteLength, + trace: exceptionutils.getStackTracePlain(e)}); + _noteConversionFailure(); + return "An unknown failure occurred. (#4)"; + } +} + +function convertFile(from, to, bytes) { + if (request.cache.conversionCallable) { + return precomputedConversionResult(from, to, bytes); + } + + _noteConversionAttempt(); + if (from == to) { + _noteConversionSuccess(); + return bytes; + } + if (from == "txt" && to == "html") { + _noteConversionSuccess(); + return (new java.lang.String(utils.renderTemplateAsString('pad/exporthtml.ejs', { + content: String(new java.lang.String(bytes, "UTF-8")).replace(/&/g, "&").replace(/</g, "<"), + pre: true + }))).getBytes("UTF-8"); + } + + request.cache.conversionByteLength = bytes.length; + request.cache.conversionCallable = + execution.scheduleTask("importexport", "doSlowFileConversion", 0, [ + from, to, bytes, request.continuation + ]); + request.cache.startTime = Date.now(); + request.continuation.suspend(45000); + _noteConversionImpossible(); + return "An unexpected error occurred."; // Shouldn't ever get here. +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/legacy_urls.js b/trunk/etherpad/src/etherpad/legacy_urls.js new file mode 100644 index 0000000..80f6f77 --- /dev/null +++ b/trunk/etherpad/src/etherpad/legacy_urls.js @@ -0,0 +1,37 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* legacy URLs only apply to the public pad.spline.inf.fu-berlin.de site. (not Pro or PNE). */ + +var _legacyURLs = { + '/ep/beta-signup': '/', + '/ep/talktostrangers': '/', + '/ep/about/pricing-eepod': '/ep/about/pricing-pro', + '/static/html/enterprise-etherpad-installguide.html': '/ep/pne-manual/', + '/static/html/eepnet/eepnet-changelog.html': '/ep/pne-manual/changelog', + '/static/html/eepnet/eepnet-installguide.html': '/ep/pne-manual/', + '/ep/blog/posts/back-online-until-open-sourced': '/ep/blog/posts/etherpad-back-online-until-open-sourced' +}; + +function checkPath() { + var p = request.path; + var match = _legacyURLs[p]; + + if (match) { + response.redirect(match); + } +} + diff --git a/trunk/etherpad/src/etherpad/licensing.js b/trunk/etherpad/src/etherpad/licensing.js new file mode 100644 index 0000000..2337456 --- /dev/null +++ b/trunk/etherpad/src/etherpad/licensing.js @@ -0,0 +1,163 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +/* + * This file used to control access restrictions for various sites like + * pad.spline.inf.fu-berlin.de or on-prem installations of etherpad, or evaluation + * editions. For the open-source effort, I have gutted out the + * restrictions. --aiba + */ + +import("sync.callsync"); +import("stringutils"); +import("fileutils.readRealFile"); +import("jsutils.*"); + +import("etherpad.globals.*"); +import("etherpad.log"); +import("etherpad.pad.padutils"); +import("etherpad.pne.pne_utils"); + +jimport("com.etherpad.Licensing"); +jimport("java.lang.System.out.println"); + +var _editionNames = { + 0: 'ETHERPAD.COM', + 1: 'PRIVATE_NETWORK_EVALUATION', + 2: 'PRIVATE_NETWORK' +}; + +function onStartup() { } + +//---------------------------------------------------------------- + +/** + * expires is a long timestamp (set to null for never expiring). + * maxUsers is also a long (set to -1 for infinite users). + */ +function generateNewKey(personName, orgName, expires, editionId, maxUsers) { + return null; +} + +function decodeLicenseInfoFromKey(key) { + return null; +} + +//---------------------------------------------------------------- + +function _getCache() { + return {}; +} + +function _readKeyFile(f) { + return null; +} + +function _readLicenseKey() { + return null; +} + +function reloadLicense() { +} + +function getLicense() { + return null; +} + +function isPrivateNetworkEdition() { + return false; +} + +// should really only be called for PNE requests. +// see etherpad.quotas module +function getMaxUsersPerPad() { + return 1e9; +} + +function getEditionId(editionName) { + return _editionNames[0]; +} + +function getEditionName(editionId) { + return _editionNames[editionId]; +} + +function isEvaluation() { + return false; +} + +function isExpired() { + return false; +} + +function isValidKey(key) { + return true; +} + +function getVersionString() { + return "0"; +} + +function isVersionTooOld() { + return false; +} + +//---------------------------------------------------------------- +// counting active users +//---------------------------------------------------------------- + +function getActiveUserQuota() { + return 1e9; +} + +function _previousMidnight() { + // return midnight of today. + var d = new Date(); + d.setHours(0); + d.setMinutes(0); + d.setSeconds(0); + d.setMilliseconds(1); // just north of midnight + return d; +} + +function _resetActiveUserStats() { +} + +function getActiveUserWindowStart() { + return null; +} + +function getActiveUserWindowHours() { + return null; +} + +function getActiveUserCount() { + return 0; +} + +function canSessionUserJoin() { + return true; +} + +function onUserJoin(userInfo) { +} + +function onUserLeave() { + // do nothing. +} + + diff --git a/trunk/etherpad/src/etherpad/log.js b/trunk/etherpad/src/etherpad/log.js new file mode 100644 index 0000000..cfc82de --- /dev/null +++ b/trunk/etherpad/src/etherpad/log.js @@ -0,0 +1,255 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon"); +import("stringutils.startsWith"); +import("sync.{callsync,callsyncIfTrue}"); +import("jsutils.*"); +import("exceptionutils"); + +import("etherpad.globals.*"); +import("etherpad.pad.padutils"); +import("etherpad.sessions"); +import("etherpad.utils.*"); + +import("etherpad.pro.pro_accounts.getSessionProAccount"); + +jimport("java.io.FileWriter"); +jimport("java.lang.System.out.println"); +jimport("java.io.File"); +jimport("net.appjet.ajstdlib.execution"); + + +function getReadableTime() { + return (new Date()).toString().split(' ').slice(0, 5).join('-'); +} + +serverhandlers.tasks.trackerAndSessionIds = function() { + var m = new Packages.scala.collection.mutable.HashMap(); + if (request.isDefined) { + try { + if (sessions.getTrackingId()) { + m.update("tracker", sessions.getTrackingId()); + } + if (sessions.getSessionId()) { + m.update("session", sessions.getSessionId()); + } + if (request.path) { + m.update("path", request.path); + } + if (request.clientAddr) { + m.update("clientAddr", request.clientAddr); + } + if (request.host) { + m.update("host", request.host); + } + if (getSessionProAccount()) { + m.update("proAccountId", getSessionProAccount().id); + } + } catch (e) { + // do nothing. + } + } + return m; +} + +function onStartup() { + var f = execution.wrapRunTask("trackerAndSessionIds", null, + java.lang.Class.forName("scala.collection.mutable.HashMap")); + net.appjet.oui.GenericLoggerUtils.setExtraPropertiesFunction(f); +} + +//---------------------------------------------------------------- +// Logfile parsing +//---------------------------------------------------------------- + +function _n(x) { + if (x < 10) { return "0"+x; } + else { return x; } +} + +function logFileName(prefix, logName, day) { + var fmt = [day.getFullYear(), _n(day.getMonth()+1), _n(day.getDate())].join('-'); + var fname = (appjet.config['logDir'] + '/'+prefix+'/' + logName + '/' + + logName + '-' + fmt + '.jslog'); + + // make sure file exists + if (!(new File(fname)).exists()) { + //log.warn("WARNING: file does not exist: "+fname); + return null; + } + + return fname; +} + +function frontendLogFileName(logName, day) { + return logFileName('frontend', logName, day); +} + +function backendLogFileName(logName, day) { + return logFileName('backend', logName, day); +} + +//---------------------------------------------------------------- +function _getRequestLogEntry() { + if (request.isDefined) { + var logEntry = { + clientAddr: request.clientAddr, + method: request.method.toLowerCase(), + scheme: request.scheme, + host: request.host, + path: request.path, + query: request.query, + referer: request.headers['Referer'], + userAgent: request.headers['User-Agent'], + statusCode: response.getStatusCode(), + } + if ('globalPadId' in request.cache) { + logEntry.padId = request.cache.globalPadId; + } + return logEntry; + } else { + return {}; + } +} + +function logRequest() { + if ((! request.isDefined) || + startsWith(request.path, COMETPATH) || + isStaticRequest()) { + return; + } + + _log("request", _getRequestLogEntry()); +} + +function _log(name, m) { + var cache = appjet.cache; + + callsyncIfTrue( + cache, + function() { return ! ('logWriters' in cache)}, + function() { cache.logWriters = {}; } + ); + + callsyncIfTrue( + cache.logWriters, + function() { return !(name in cache.logWriters) }, + function() { + lw = new net.appjet.oui.GenericLogger('frontend', name, true); + if (! isProduction()) { + lw.setEchoToStdOut(true); + } + lw.start(); + cache.logWriters[name] = lw; + }); + + var lw = cache.logWriters[name]; + if (typeof(m) == 'object') { + lw.logObject(m); + } else { + lw.log(m); + } +} + +function custom(name, m) { + _log(name, m); +} + +function _stampedMessage(m) { + var obj = {}; + if (typeof(m) == 'string') { + obj.message = m; + } else { + eachProperty(m, function(k, v) { + obj[k] = v; + }); + } + // stamp message with pad and path + if (request.isDefined) { + obj.path = request.path; + } + + var currentPad = padutils.getCurrentPad(); + if (currentPad) { + obj.currentPad = currentPad; + } + + return obj; +} + +//---------------------------------------------------------------- +// logException +//---------------------------------------------------------------- + +function logException(ex) { + if (typeof(ex) != 'object' || ! (ex instanceof java.lang.Throwable)) { + ex = new java.lang.RuntimeException(String(ex)); + } + // NOTE: ex is always a java.lang.Throwable + var m = _getRequestLogEntry(); + m.jsTrace = exceptionutils.getStackTracePlain(ex); + var s = new java.io.StringWriter(); + ex.printStackTrace(new java.io.PrintWriter(s)); + m.trace = s.toString(); + _log("exception", m); +} + +function callCatchingExceptions(func) { + try { + return func(); + } + catch (e) { + logException(toJavaException(e)); + } + return undefined; +} + +//---------------------------------------------------------------- +// warning +//---------------------------------------------------------------- +function warn(m) { + _log("warn", _stampedMessage(m)); +} + +//---------------------------------------------------------------- +// info +//---------------------------------------------------------------- +function info(m) { + _log("info", _stampedMessage(m)); +} + +function onUserJoin(userId) { + function doUpdate() { + sqlobj.update('pad_cookie_userids', {id: userId}, {lastActiveDate: new Date()}); + } + try { + sqlcommon.inTransaction(function() { + if (sqlobj.selectSingle('pad_cookie_userids', {id: userId})) { + doUpdate(); + } else { + sqlobj.insert('pad_cookie_userids', + {id: userId, createdDate: new Date(), lastActiveDate: new Date()}); + } + }); + } + catch (e) { + sqlcommon.inTransaction(function() { + doUpdate(); + }); + } +} diff --git a/trunk/etherpad/src/etherpad/metrics/metrics.js b/trunk/etherpad/src/etherpad/metrics/metrics.js new file mode 100644 index 0000000..435a5be --- /dev/null +++ b/trunk/etherpad/src/etherpad/metrics/metrics.js @@ -0,0 +1,438 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.log.frontendLogFileName"); +import("jsutils.eachProperty"); +import("stringutils.startsWith"); +import("fileutils.eachFileLine"); + +jimport("java.lang.System.out.println"); + +var _idleTime = 5*60*1000 // 5 minutes? + +function _isPadUrl(url) { + return url != '/' && ! startsWith(url, '/ep/'); +} + +function VisitData(url, referer) { + this.url = url; + this.referer = referer; + this.__defineGetter__('isPadVisit', function() { + return _isPadUrl(this.url); + }); +} +VisitData.prototype.toString = function() { + var re = new RegExp("^https?://"+request.host); + if (this.referer && ! re.test(this.referer)) { + return this.url+", from "+this.referer; + } else { + return this.url; + } +} + +function Event(time, type, data) { + this.time = time; + this.type = type; + this.data = data; +} +Event.prototype.toString = function() { + return "("+this.type+" "+this.data+" @ "+this.time.getTime()+")"; +} + +function Flow(sessionKey, startEvent) { + this.sessionKey = sessionKey; + this.events = []; + this.visitedPaths = {}; + var visitCount = 0; + var visitsCache; + this._updateVisitedPaths = function(url) { + if (! this.visitedPaths[url]) { + this.visitedPaths[url] = [visitCount]; + } else { + this.visitedPaths[url].push(visitCount); + } + } + var isInPad = 0; + this.push = function(evt) { + evt.flow = this; + this.events.push(evt); + if (evt.type == 'visit') { + this._updateVisitedPaths(evt.data.url); + if (_isPadUrl(evt.data.url)) { + this._updateVisitedPaths("(pad)"); + } + visitCount++; + visitsCache = undefined; + } else if (evt.type == 'userjoin') { + isInPad++; + } else if (evt.type == 'userleave') { + isInPad--; + } + } + this.__defineGetter__("isInPad", function() { return isInPad > 0; }); + this.__defineGetter__("lastEvent", function() { + return this.events[this.events.length-1]; + }); + this.__defineGetter__("visits", function() { + if (! visitsCache) { + visitsCache = this.events.filter(function(x) { return x.type == "visit" }); + } + return visitsCache; + }); + startEvent.flow = this; + this.push(startEvent); +} +Flow.prototype.toString = function() { + return "["+this.events.map(function(x) { return x.toString(); }).join(", ")+"]"; +} +Flow.prototype.includesVisit = function(path, index, useExactIndexMatch) { + if (! this.visitedPaths[path]) return false; + if (useExactIndexMatch) { + return this.visitedPaths[path].some(function(x) { return x == index }); + } else { + if (index) { + for (var i = 0; i < this.visitedPaths[path].length; ++i) { + if (this.visitedPaths[path][i] >= index) + return this.visitedPaths[path][i]; + } + return false; + } else { + return true; + } + } +} +Flow.prototype.visitIndices = function(path) { + return this.visitedPaths[path] || []; +} + +function getKeyForDate(date) { + return date.getYear()+":"+date.getMonth()+":"+date.getDay(); +} + +function parseEvents(dates) { + if (! appjet.cache["metrics-events"]) { + appjet.cache["metrics-events"] = {}; + } + var events = {}; + function eventArray(key) { + if (! events[key]) { + events[key] = []; + } + return events[key]; + } + + dates.sort(function(a, b) { return a.getTime() - b.getTime(); }); + dates.forEach(function(day) { + if (! appjet.cache["metrics-events"][getKeyForDate(day)]) { + var daysEvents = {}; + function daysEventArray(key) { + if (! daysEvents[key]) { + daysEvents[key] = []; + } + return daysEvents[key]; + } + var requestLog = frontendLogFileName("request", day); + if (requestLog) { + eachFileLine(requestLog, function(line) { + var s = line.split("\t"); + var sessionKey = s[3]; + if (sessionKey == "-") { return; } + var time = new Date(Number(s[1])); + var path = s[7]; + var referer = (s[9] == "-" ? null : s[9]); + var userAgent = s[10]; + var statusCode = s[5]; + // Remove bots and other automatic or irrelevant requests. + // There's got to be something better than a whitelist. + if (userAgent.indexOf("Mozilla") < 0 && + userAgent.indexOf("Opera") < 0) { + return; + } + if (path == "/favicon.ico") { return; } + daysEventArray(sessionKey).push(new Event(time, "visit", new VisitData(path, referer))); + }); + } + var padEventLog = frontendLogFileName("padevents", day); + if (padEventLog) { + eachFileLine(padEventLog, function(line) { + var s = line.split("\t"); + var sessionKey = s[7]; + if (sessionKey == "-") { return; } + var time = new Date(Number(s[1])); + var padId = s[3]; + var evt = s[2]; + daysEventArray(sessionKey).push(new Event(time, evt, padId)); + }); + } + var chatLog = frontendLogFileName("chat", day); + if (chatLog) { + eachFileLine(chatLog, function(line) { + var s = line.split("\t"); + var sessionKey = s[4]; + if (sessionKey == "-") { return; } + var time = new Date(Number(s[1])); + var padId = s[2]; + daysEventArray(sessionKey).push(new Event(time, "chat", padId)); + }); + } + eachProperty(daysEvents, function(k, v) { + v.sort(function(a, b) { return a.time.getTime() - b.time.getTime()}); + }); + appjet.cache["metrics-events"][getKeyForDate(day)] = daysEvents; + } + eachProperty(appjet.cache["metrics-events"][getKeyForDate(day)], function(k, v) { + Array.prototype.push.apply(eventArray(k), v); + }); + }); + + return events; +} + +function getFlows(startDate, endDate) { + if (! endDate) { endDate = startDate; } + if (! appjet.cache.flows || request.params.clearCache == "1") { + appjet.cache.flows = {}; + } + if (appjet.cache.flows[getKeyForDate(startDate)+"-"+getKeyForDate(endDate)]) { + return appjet.cache.flows[getKeyForDate(startDate)+"-"+getKeyForDate(endDate)]; + } + + var datesForEvents = []; + for (var i = startDate; i.getTime() <= endDate.getTime(); i = new Date(i.getTime()+86400*1000)) { + datesForEvents.push(i); + } + + var events = parseEvents(datesForEvents); + var flows = {}; + + eachProperty(events, function(k, eventArray) { + flows[k] = []; + function lastFlow() { + var f = flows[k]; + if (f.length > 0) { + return f[f.length-1]; + } + } + var lastTime = 0; + eventArray.forEach(function(evt) { + var l = lastFlow(); + + if (l && (l.lastEvent.time.getTime() + _idleTime > evt.time.getTime() || l.isInPad)) { + l.push(evt); + } else { + flows[k].push(new Flow(k, evt)); + } + }); + }); + appjet.cache.flows[getKeyForDate(startDate)+"-"+getKeyForDate(endDate)] = flows; + return flows; +} + +function _uniq(array) { + var seen = {}; + return array.filter(function(x) { + if (seen[x]) { + return false; + } + seen[x] = true; + return true; + }); +} + +function getFunnel(startDate, endDate, pathsArray, useConsecutivePaths) { + var flows = getFlows(startDate, endDate) + + var flowsAtStep = pathsArray.map(function() { return []; }); + eachProperty(flows, function(k, flowArray) { + flowArray.forEach(function(flow) { + if (flow.includesVisit(pathsArray[0])) { + flowsAtStep[0].push({f: flow, i: flow.visitIndices(pathsArray[0])}); + } + }); + }); + for (var i = 0; i < pathsArray.length-1; ++i) { + flowsAtStep[i].forEach(function(fobj) { + var newIndices = fobj.i.map(function(index) { + var nextIndex = + fobj.f.includesVisit(pathsArray[i+1], index+1, useConsecutivePaths); + if (nextIndex !== false) { + return (useConsecutivePaths ? index+1 : nextIndex); + } + }).filter(function(x) { return x !== undefined; }); + if (newIndices.length > 0) { + flowsAtStep[i+1].push({f: fobj.f, i: newIndices}); + } + }); + } + return { + flows: flowsAtStep.map(function(x) { return x.map(function(y) { return y.f; }); }), + visitCounts: flowsAtStep.map(function(x) { return x.length; }), + visitorCounts: flowsAtStep.map(function(x) { + return _uniq(x.map(function(y) { return y.f.sessionKey; })).length + }) + }; +} + +function makeHistogram(array) { + var counts = {}; + for (var i = 0; i < array.length; ++i) { + var value = array[i] + if (! counts[value]) { + counts[value] = 0; + } + counts[value]++; + } + var histogram = []; + eachProperty(counts, function(k, v) { + histogram.push({value: k, count: v, fraction: (v / array.length)}); + }); + histogram.sort(function(a, b) { return b.count - a.count; }); + return histogram; +} + +function getOrigins(startDate, endDate, useReferer, shouldAggregatePads) { + var key = (useReferer ? "referer" : "url"); + var flows = getFlows(startDate, endDate); + + var sessionKeyFirsts = []; + var flowFirsts = []; + eachProperty(flows, function(k, flowArray) { + if (flowArray[0].visits[0] && flowArray[0].visits[0].data && + flowArray[0].visits[0].data[key]) { + var path = flowArray[0].visits[0].data[key]; + sessionKeyFirsts.push( + (shouldAggregatePads && ! useReferer && _isPadUrl(path) ? + "(pad)" : path)); + } + flowArray.forEach(function(flow) { + if (flow.visits[0] && flow.visits[0].data && + flow.visits[0].data[key]) { + var path = flow.visits[0].data[key]; + flowFirsts.push( + (shouldAggregatePads && ! useReferer && _isPadUrl(path) ? + "(pad)" : path)); + } + }); + }); + + if (useReferer) { + flowFirsts = flowFirsts.filter(function(x) { return ! startsWith(x, "http://pad.spline.inf.fu-berlin.de"); }); + sessionKeyFirsts = sessionKeyFirsts.filter(function(x) { return ! startsWith(x, "http://pad.spline.inf.fu-berlin.de"); }); + } + + return { + flowFirsts: makeHistogram(flowFirsts), + sessionKeyFirsts: makeHistogram(sessionKeyFirsts) + } +} + +function getExits(startDate, endDate, src, shouldAggregatePads) { + var flows = getFlows(startDate, endDate); + + var exits = []; + + eachProperty(flows, function(k, flowArray) { + flowArray.forEach(function(flow) { + var indices = flow.visitIndices(src); + for (var i = 0; i < indices.length; ++i) { + if (indices[i]+1 < flow.visits.length) { + if (src != flow.visits[indices[i]+1].data.url) { + exits.push(flow.visits[indices[i]+1]); + } + } else { + exits.push("(nothing)"); + } + } + }); + }); + return { + nextVisits: exits, + histogram: makeHistogram(exits.map(function(x) { + if (typeof(x) == 'string') return x; + return ((! shouldAggregatePads) || ! _isPadUrl(x.data.url) ? + x.data.url : "(pad)" ) + })) + } +} + +jimport("org.jfree.data.general.DefaultPieDataset"); +jimport("org.jfree.chart.plot.PiePlot"); +jimport("org.jfree.chart.ChartUtilities"); +jimport("org.jfree.chart.JFreeChart"); + +function _fToPct(f) { + return Math.round(f*10000)/100; +} + +function _shorten(str) { + if (startsWith(str, "http://")) { + str = str.substring("http://".length); + } + var len = 35; + if (str.length > len) { + return str.substring(0, len-3)+"..." + } else { + return str; + } +} + +function respondWithPieChart(name, histogram) { + var width = 900; + var height = 300; + + var ds = new DefaultPieDataset(); + + var cumulative = 0; + var other = 0; + var otherCount = 0; + histogram.forEach(function(x, i) { + cumulative += x.fraction; + if (cumulative < 0.98 && x.fraction > .01) { + ds.setValue(_shorten(x.value)+"\n ("+x.count+" visits - "+_fToPct(x.fraction)+"%)", x.fraction); + } else { + other += x.fraction; + otherCount += x.count; + } + }); + if (other > 0) { + ds.setValue("Other ("+otherCount + " visits - "+_fToPct(other)+"%)", other); + } + + var piePlot = new PiePlot(ds); + + var chart = new JFreeChart(piePlot); + chart.setTitle(name); + chart.removeLegend(); + + var jos = new java.io.ByteArrayOutputStream(); + ChartUtilities.writeChartAsJPEG( + jos, 1.0, chart, width, height); + + response.setContentType('image/jpeg'); + response.writeBytes(jos.toByteArray()); +} + + + + + + + + + + + + diff --git a/trunk/etherpad/src/etherpad/pad/activepads.js b/trunk/etherpad/src/etherpad/pad/activepads.js new file mode 100644 index 0000000..07f5e2e --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/activepads.js @@ -0,0 +1,52 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("jsutils.cmp"); + +jimport("net.appjet.common.util.LimitedSizeMapping"); + +var HISTORY_SIZE = 100; + +function _getMap() { + if (!appjet.cache['activepads']) { + appjet.cache['activepads'] = { + map: new LimitedSizeMapping(HISTORY_SIZE) + }; + } + return appjet.cache['activepads'].map; +} + +function touch(padId) { + _getMap().put(padId, +(new Date)); +} + +function getActivePads() { + var m = _getMap(); + var a = m.listAllKeys().toArray(); + var activePads = []; + for (var i = 0; i < a.length; i++) { + activePads.push({ + padId: a[i], + timestamp: m.get(a[i]) + }); + } + + activePads.sort(function(a,b) { return cmp(b.timestamp,a.timestamp); }); + return activePads; +} + + + diff --git a/trunk/etherpad/src/etherpad/pad/chatarchive.js b/trunk/etherpad/src/etherpad/pad/chatarchive.js new file mode 100644 index 0000000..2f8e33a --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/chatarchive.js @@ -0,0 +1,67 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("stringutils"); +import("etherpad.log"); + +jimport("java.lang.System.out.println"); + +function onChatMessage(pad, senderUserInfo, msg) { + pad.appendChatMessage({ + name: senderUserInfo.name, + userId: senderUserInfo.userId, + time: +(new Date), + lineText: msg.lineText + }); +} + +function getRecentChatBlock(pad, howMany) { + var numMessages = pad.getNumChatMessages(); + var firstToGet = Math.max(0, numMessages - howMany); + + return getChatBlock(pad, firstToGet, numMessages); +} + +function getChatBlock(pad, start, end) { + if (start < 0) { + start = 0; + } + if (end > pad.getNumChatMessages()) { + end = pad.getNumChatMessages(); + } + + var historicalAuthorData = {}; + var lines = []; + var block = {start: start, end: end, + historicalAuthorData: historicalAuthorData, + lines: lines}; + + for(var i=start; i<end; i++) { + var x = pad.getChatMessage(i); + var userId = x.userId; + if (! historicalAuthorData[userId]) { + historicalAuthorData[userId] = (pad.getAuthorData(userId) || {}); + } + lines.push({ + name: x.name, + time: x.time, + userId: x.userId, + lineText: x.lineText + }); + } + + return block; +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/pad/dbwriter.js b/trunk/etherpad/src/etherpad/pad/dbwriter.js new file mode 100644 index 0000000..233622b --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/dbwriter.js @@ -0,0 +1,338 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("execution"); +import("profiler"); + +import("etherpad.pad.model"); +import("etherpad.pad.model.accessPadGlobal"); +import("etherpad.log"); +import("etherpad.utils"); + +jimport("net.appjet.oui.exceptionlog"); +jimport("java.util.concurrent.ConcurrentHashMap"); +jimport("java.lang.System.out.println"); + +var MIN_WRITE_INTERVAL_MS = 2000; // 2 seconds +var MIN_WRITE_DELAY_NOTIFY_MS = 2000; // 2 seconds +var AGE_FOR_PAD_FLUSH_MS = 5*60*1000; // 5 minutes +var DBUNWRITABLE_WRITE_DELAY_MS = 30*1000; // 30 seconds + +// state is { constant: true }, { constant: false }, { trueAfter: timeInMs } +function setWritableState(state) { + _dbwriter().dbWritable = state; +} + +function getWritableState() { + return _dbwriter().dbWritable; +} + +function isDBWritable() { + return _isDBWritable(); +} + +function _isDBWritable() { + var state = _dbwriter().dbWritable; + if (typeof state != "object") { + return true; + } + else if (state.constant !== undefined) { + return !! state.constant; + } + else if (state.trueAfter !== undefined) { + return (+new Date()) > state.trueAfter; + } + else return true; +} + +function getWritableStateDescription(state) { + var v = _isDBWritable(); + var restOfMessage = ""; + if (state.trueAfter !== undefined) { + var now = +new Date(); + var then = state.trueAfter; + var diffSeconds = java.lang.String.format("%.1f", Math.abs(now - then)/1000); + if (now < then) { + restOfMessage = " until "+diffSeconds+" seconds from now"; + } + else { + restOfMessage = " since "+diffSeconds+" seconds ago"; + } + } + return v+restOfMessage; +} + +function _dbwriter() { + return appjet.cache.dbwriter; +} + +function onStartup() { + appjet.cache.dbwriter = {}; + var dbwriter = _dbwriter(); + dbwriter.pendingWrites = new ConcurrentHashMap(); + dbwriter.scheduledFor = new ConcurrentHashMap(); // padId --> long + dbwriter.dbWritable = { constant: true }; + + execution.initTaskThreadPool("dbwriter", 4); + // we don't wait for scheduled tasks in the infreq pool to run and complete + execution.initTaskThreadPool("dbwriter_infreq", 1); + + _scheduleCheckForStalePads(); +} + +function _scheduleCheckForStalePads() { + execution.scheduleTask("dbwriter_infreq", "checkForStalePads", AGE_FOR_PAD_FLUSH_MS, []); +} + +function onShutdown() { + log.info("Doing final DB writes before shutdown..."); + var success = execution.shutdownAndWaitOnTaskThreadPool("dbwriter", 10000); + if (! success) { + log.warn("ERROR! DB WRITER COULD NOT SHUTDOWN THREAD POOL!"); + } +} + +function _logException(e) { + var exc = utils.toJavaException(e); + log.warn("writeAllToDB: Error writing to SQL! Written to exceptions.log: "+exc); + log.logException(exc); + exceptionlog.apply(exc); +} + +function taskFlushPad(padId, reason) { + var dbwriter = _dbwriter(); + if (! _isDBWritable()) { + // DB is unwritable, delay + execution.scheduleTask("dbwriter_infreq", "flushPad", DBUNWRITABLE_WRITE_DELAY_MS, [padId, reason]); + return; + } + + model.accessPadGlobal(padId, function(pad) { + writePadNow(pad, true); + }, "r"); + + log.info("taskFlushPad: flushed "+padId+(reason?(" (reason: "+reason+")"):'')); +} + +function taskWritePad(padId) { + var dbwriter = _dbwriter(); + if (! _isDBWritable()) { + // DB is unwritable, delay + dbwriter.scheduledFor.put(padId, (+(new Date)+DBUNWRITABLE_WRITE_DELAY_MS)); + execution.scheduleTask("dbwriter", "writePad", DBUNWRITABLE_WRITE_DELAY_MS, [padId]); + return; + } + + profiler.reset(); + var t1 = profiler.rcb("lock wait"); + model.accessPadGlobal(padId, function(pad) { + t1(); + _dbwriter().pendingWrites.remove(padId); // do this first + + var success = false; + try { + var t2 = profiler.rcb("write"); + writePadNow(pad); + t2(); + + success = true; + } + finally { + if (! success) { + log.warn("DB WRITER FAILED TO WRITE PAD: "+padId); + } + profiler.print(); + } + }, "r"); +} + +function taskCheckForStalePads() { + // do this first + _scheduleCheckForStalePads(); + + if (! _isDBWritable()) return; + + // get "active" pads into an array + var padIter = appjet.cache.pads.meta.keySet().iterator(); + var padList = []; + while (padIter.hasNext()) { padList.push(padIter.next()); } + + var numStale = 0; + + for (var i = 0; i < padList.length; i++) { + if (! _isDBWritable()) break; + var p = padList[i]; + if (model.isPadLockHeld(p)) { + // skip it, don't want to lock up stale pad flusher + } + else { + accessPadGlobal(p, function(pad) { + if (pad.exists()) { + var padAge = (+new Date()) - pad._meta.status.lastAccess; + if (padAge > AGE_FOR_PAD_FLUSH_MS) { + writePadNow(pad, true); + numStale++; + } + } + }, "r"); + } + } + + log.info("taskCheckForStalePads: flushed "+numStale+" stale pads"); +} + +function notifyPadDirty(padId) { + var dbwriter = _dbwriter(); + if (! dbwriter.pendingWrites.containsKey(padId)) { + dbwriter.pendingWrites.put(padId, "pending"); + dbwriter.scheduledFor.put(padId, (+(new Date)+MIN_WRITE_INTERVAL_MS)); + execution.scheduleTask("dbwriter", "writePad", MIN_WRITE_INTERVAL_MS, [padId]); + } +} + +function scheduleFlushPad(padId, reason) { + execution.scheduleTask("dbwriter_infreq", "flushPad", 0, [padId, reason]); +} + +/*function _dbwriterLoopBody(executor) { + try { + var info = writeAllToDB(executor); + if (!info.boring) { + log.info("DB writer: "+info.toSource()); + } + java.lang.Thread.sleep(Math.max(0, MIN_WRITE_INTERVAL_MS - info.elapsed)); + } + catch (e) { + _logException(e); + java.lang.Thread.sleep(MIN_WRITE_INTERVAL_MS); + } +} + +function _startInThread(name, func) { + (new Thread(new Runnable({ + run: function() { + func(); + } + }), name)).start(); +} + +function killDBWriterThreadAndWait() { + appjet.cache.abortDBWriter = true; + while (appjet.cache.runningDBWriter) { + java.lang.Thread.sleep(100); + } +}*/ + +/*function writeAllToDB(executor, andFlush) { + if (!executor) { + executor = new ScheduledThreadPoolExecutor(NUM_WRITER_THREADS); + } + + profiler.reset(); + var startWriteTime = profiler.time(); + var padCount = new AtomicInteger(0); + var writeCount = new AtomicInteger(0); + var removeCount = new AtomicInteger(0); + + // get pads into an array + var padIter = appjet.cache.pads.meta.keySet().iterator(); + var padList = []; + while (padIter.hasNext()) { padList.push(padIter.next()); } + + var latch = new CountDownLatch(padList.length); + + for (var i = 0; i < padList.length; i++) { + _spawnCall(executor, function(p) { + try { + var padWriteResult = {}; + accessPadGlobal(p, function(pad) { + if (pad.exists()) { + padCount.getAndIncrement(); + padWriteResult = writePad(pad, andFlush); + if (padWriteResult.didWrite) writeCount.getAndIncrement(); + if (padWriteResult.didRemove) removeCount.getAndIncrement(); + } + }, "r"); + } catch (e) { + _logException(e); + } finally { + latch.countDown(); + } + }, padList[i]); + } + + // wait for them all to finish + latch.await(); + + var endWriteTime = profiler.time(); + var elapsed = Math.round((endWriteTime - startWriteTime)/1000)/1000; + var interesting = (writeCount.get() > 0 || removeCount.get() > 0); + + var obj = {padCount:padCount.get(), writeCount:writeCount.get(), elapsed:elapsed, removeCount:removeCount.get()}; + if (! interesting) obj.boring = true; + if (interesting) { + profiler.record("writeAll", profiler.time()-startWriteTime); + profiler.print(); + } + + return obj; +}*/ + +function writePadNow(pad, andFlush) { + var didWrite = false; + var didRemove = false; + + if (pad.exists()) { + var dbUpToDate = false; + if (pad._meta.status.dirty) { + /*log.info("Writing pad "+pad.getId());*/ + pad._meta.status.dirty = false; + //var t1 = +new Date(); + pad.writeToDB(); + //var t2 = +new Date(); + didWrite = true; + + //log.info("Wrote pad "+pad.getId()+" in "+(t2-t1)+" ms."); + + var now = +(new Date); + var sched = _dbwriter().scheduledFor.get(pad.getId()); + if (sched) { + var delay = now - sched; + if (delay > MIN_WRITE_DELAY_NOTIFY_MS) { + log.warn("dbwriter["+pad.getId()+"] behind schedule by "+delay+"ms"); + } + _dbwriter().scheduledFor.remove(pad.getId()); + } + } + if (andFlush) { + // remove from cache + model.removeFromMemory(pad); + didRemove = true; + } + } + return {didWrite:didWrite, didRemove:didRemove}; +} + +/*function _spawnCall(executor, func, varargs) { + var args = Array.prototype.slice.call(arguments, 2); + var that = this; + executor.schedule(new Runnable({ + run: function() { + func.apply(that, args); + } + }), 0, TimeUnit.MICROSECONDS); +}*/ + diff --git a/trunk/etherpad/src/etherpad/pad/easysync2migration.js b/trunk/etherpad/src/etherpad/pad/easysync2migration.js new file mode 100644 index 0000000..c2a1523 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/easysync2migration.js @@ -0,0 +1,675 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import("etherpad.collab.ace.easysync1"); +import("etherpad.collab.ace.easysync2"); +import("sqlbase.sqlbase"); +import("fastJSON"); +import("sqlbase.sqlcommon.*"); +import("etherpad.collab.ace.contentcollector.sanitizeUnicode"); + +function _getPadStringArrayNumId(padId, arrayName) { + var stmnt = "SELECT NUMID FROM "+btquote("PAD_"+arrayName.toUpperCase()+"_META")+ + " WHERE ("+btquote("ID")+" = ?)"; + + return withConnection(function(conn) { + var pstmnt = conn.prepareStatement(stmnt); + return closing(pstmnt, function() { + pstmnt.setString(1, padId); + var resultSet = pstmnt.executeQuery(); + return closing(resultSet, function() { + if (! resultSet.next()) { + return -1; + } + return resultSet.getInt(1); + }); + }); + }); +} + +function _getEntirePadStringArray(padId, arrayName) { + var numId = _getPadStringArrayNumId(padId, arrayName); + if (numId < 0) { + return []; + } + + var stmnt = "SELECT PAGESTART, OFFSETS, DATA FROM "+btquote("PAD_"+arrayName.toUpperCase()+"_TEXT")+ + " WHERE ("+btquote("NUMID")+" = ?)"; + + return withConnection(function(conn) { + var pstmnt = conn.prepareStatement(stmnt); + return closing(pstmnt, function() { + pstmnt.setInt(1, numId); + var resultSet = pstmnt.executeQuery(); + return closing(resultSet, function() { + var array = []; + while (resultSet.next()) { + var pageStart = resultSet.getInt(1); + var lengthsString = resultSet.getString(2); + var dataString = resultSet.getString(3); + var dataIndex = 0; + var arrayIndex = pageStart; + lengthsString.split(',').forEach(function(len) { + if (len) { + len = Number(len); + array[arrayIndex] = dataString.substr(dataIndex, len); + dataIndex += len; + } + arrayIndex++; + }); + } + return array; + }); + }); + }); +} + +function _overwriteEntirePadStringArray(padId, arrayName, array) { + var numId = _getPadStringArrayNumId(padId, arrayName); + if (numId < 0) { + // generate numId + withConnection(function(conn) { + var ps = conn.prepareStatement("INSERT INTO "+btquote("PAD_"+arrayName.toUpperCase()+"_META")+ + " ("+btquote("ID")+") VALUES (?)", + java.sql.Statement.RETURN_GENERATED_KEYS); + closing(ps, function() { + ps.setString(1, padId); + ps.executeUpdate(); + var keys = ps.getGeneratedKeys(); + if ((! keys) || (! keys.next())) { + throw new Error("Couldn't generate key for "+arrayName+" table for pad "+padId); + } + closing(keys, function() { + numId = keys.getInt(1); + }); + }); + }); + } + + withConnection(function(conn) { + + var stmnt1 = "DELETE FROM "+btquote("PAD_"+arrayName.toUpperCase()+"_TEXT")+ + " WHERE ("+btquote("NUMID")+" = ?)"; + var pstmnt1 = conn.prepareStatement(stmnt1); + closing(pstmnt1, function() { + pstmnt1.setInt(1, numId); + pstmnt1.executeUpdate(); + }); + + var PAGE_SIZE = 20; + var numPages = Math.floor((array.length-1) / PAGE_SIZE + 1); + + var PAGES_PER_BATCH = 20; + var curPage = 0; + + while (curPage < numPages) { + var stmnt2 = "INSERT INTO "+btquote("PAD_"+arrayName.toUpperCase()+"_TEXT")+ + " ("+btquote("NUMID")+", "+btquote("PAGESTART")+", "+btquote("OFFSETS")+ + ", "+btquote("DATA")+") VALUES (?, ?, ?, ?)"; + var pstmnt2 = conn.prepareStatement(stmnt2); + closing(pstmnt2, function() { + for(var n=0;n<PAGES_PER_BATCH && curPage < numPages;n++) { + var pageStart = curPage*PAGE_SIZE; + var r = pageStart; + var lengthPieces = []; + var dataPieces = []; + for(var i=0;i<PAGE_SIZE;i++) { + var str = (array[r] || ''); + dataPieces.push(str); + lengthPieces.push(String(str.length || '')); + r++; + } + var lengthsString = lengthPieces.join(','); + var dataString = dataPieces.join(''); + pstmnt2.setInt(1, numId); + pstmnt2.setInt(2, pageStart); + pstmnt2.setString(3, lengthsString); + pstmnt2.setString(4, dataString); + pstmnt2.addBatch(); + + curPage++; + } + pstmnt2.executeBatch(); + }); + } + }); + +} + +function _getEntirePadJSONArray(padId, arrayName) { + var array = _getEntirePadStringArray(padId, arrayName); + for(var k in array) { + if (array[k]) { + array[k] = fastJSON.parse(array[k]); + } + } + return array; +} + +function _overwriteEntirePadJSONArray(padId, arrayName, objArray) { + var array = []; + for(var k in objArray) { + if (objArray[k]) { + array[k] = fastJSON.stringify(objArray[k]); + } + } + _overwriteEntirePadStringArray(padId, arrayName, array); +} + +function _getMigrationPad(padId) { + var oldRevs = _getEntirePadStringArray(padId, "revs"); + var oldRevMeta = _getEntirePadJSONArray(padId, "revmeta"); + var oldAuthors = _getEntirePadJSONArray(padId, "authors"); + var oldMeta = sqlbase.getJSON("PAD_META", padId); + + var oldPad = { + getHeadRevisionNumber: function() { + return oldMeta.head; + }, + getRevisionChangesetString: function(r) { + return oldRevs[r]; + }, + getRevisionAuthor: function(r) { + return oldMeta.numToAuthor[oldRevMeta[r].a]; + }, + getId: function() { return padId; }, + getKeyRevisionNumber: function(r) { + return Math.floor(r / oldMeta.keyRevInterval) * oldMeta.keyRevInterval; + }, + getInternalRevisionText: function(r) { + if (r != oldPad.getKeyRevisionNumber(r)) { + throw new Error("Assertion error: "+r+" != "+oldPad.getKeyRevisionNumber(r)); + } + return oldRevMeta[r].atext.text; + }, + _meta: oldMeta, + getAuthorArrayEntry: function(n) { + return oldAuthors[n]; + }, + getRevMetaArrayEntry: function(r) { + return oldRevMeta[r]; + } + }; + + var apool = new easysync2.AttribPool(); + var newRevMeta = []; + var newAuthors = []; + var newRevs = []; + var metaPropsToDelete = []; + + var newPad = { + pool: function() { return apool; }, + setAuthorArrayEntry: function(n, obj) { + newAuthors[n] = obj; + }, + setRevMetaArrayEntry: function(r, obj) { + newRevMeta[r] = obj; + }, + setRevsArrayEntry: function(r, cs) { + newRevs[r] = cs; + }, + deleteMetaProp: function(propName) { + metaPropsToDelete.push(propName); + } + }; + + function writeToDB() { + var newMeta = {}; + for(var k in oldMeta) { + newMeta[k] = oldMeta[k]; + } + metaPropsToDelete.forEach(function(p) { + delete newMeta[p]; + }); + + sqlbase.putJSON("PAD_META", padId, newMeta); + sqlbase.putJSON("PAD_APOOL", padId, apool.toJsonable()); + + _overwriteEntirePadStringArray(padId, "revs", newRevs); + _overwriteEntirePadJSONArray(padId, "revmeta", newRevMeta); + _overwriteEntirePadJSONArray(padId, "authors", newAuthors); + } + + return {oldPad:oldPad, newPad:newPad, writeToDB:writeToDB}; +} + +function migratePad(padId) { + + var mpad = _getMigrationPad(padId); + var oldPad = mpad.oldPad; + var newPad = mpad.newPad; + + var headRev = oldPad.getHeadRevisionNumber(); + var txt = "\n"; + var newChangesets = []; + var newChangesetAuthorNums = []; + var cumCs = easysync2.Changeset.identity(1); + + var pool = newPad.pool(); + + var isExtraFinalNewline = false; + + function authorToNewNum(author) { + return pool.putAttrib(['author',author||'']); + } + + //S var oldTotalChangesetSize = 0; + //S var newTotalChangesetSize = 0; + //S function stringSize(str) { + //S return new java.lang.String(str).getBytes("UTF-8").length; + //S } + + //P var diffTotals = []; + for(var r=0;r<=headRev;r++) { + //P var times = []; + //P times.push(+new Date); + var author = oldPad.getRevisionAuthor(r); + //P times.push(+new Date); + newChangesetAuthorNums.push(authorToNewNum(author)); + + var newCs, newText; + if (r == 0) { + newText = oldPad.getInternalRevisionText(0); + newCs = getInitialChangeset(newText, pool, author); + //S oldTotalChangesetSize += stringSize(pad.getRevisionChangesetString(0)); + } + else { + var oldCsStr = oldPad.getRevisionChangesetString(r); + //S oldTotalChangesetSize += stringSize(oldCsStr); + //P times.push(+new Date); + var oldCs = easysync1.Changeset.decodeFromString(oldCsStr); + //P times.push(+new Date); + + /*var newTextFromOldCs = oldCs.applyToText(txt); + if (newTextFromOldCs.charAt(newTextFromOldCs.length-1) != '\n') { + var e = new Error("Violation of final newline property at revision "+r); + e.finalNewlineMissing = true; + throw e; + }*/ + //var newCsNewTxt1 = upgradeChangeset(oldCs, txt, pool, author); + var oldIsExtraFinalNewline = isExtraFinalNewline; + var newCsNewTxt2 = upgradeChangeset(oldCs, txt, pool, author, isExtraFinalNewline); + //P times.push(+new Date); + /*if (newCsNewTxt1[1] != newCsNewTxt2[1]) { + _putFile(newCsNewTxt1[1], "/tmp/file1"); + _putFile(newCsNewTxt2[1], "/tmp/file2"); + throw new Error("MISMATCH 1"); + } + if (newCsNewTxt1[0] != newCsNewTxt2[0]) { + _putFile(newCsNewTxt1[0], "/tmp/file1"); + _putFile(newCsNewTxt2[0], "/tmp/file2"); + throw new Error("MISMATCH 0"); + }*/ + newCs = newCsNewTxt2[0]; + newText = newCsNewTxt2[1]; + isExtraFinalNewline = newCsNewTxt2[2]; + + /*if (oldIsExtraFinalNewline || isExtraFinalNewline) { + System.out.print("\nnewline fix for rev "+r+"/"+headRev+"... "); + }*/ + } + + var oldText = txt; + newChangesets.push(newCs); + txt = newText; + //System.out.println(easysync2.Changeset.toBaseTen(cumCs)+" * "+ + //easysync2.Changeset.toBaseTen(newCs)); + /*cumCs = easysync2.Changeset.checkRep(easysync2.Changeset.compose(cumCs, newCs)); + if (easysync2.Changeset.applyToText(cumCs, "\n") != txt) { + throw new Error("cumCs mismatch"); + }*/ + + //P times.push(+new Date); + + easysync2.Changeset.checkRep(newCs); + //P times.push(+new Date); + var origText = txt; + if (isExtraFinalNewline) { + origText = origText.slice(0, -1); + } + if (r == oldPad.getKeyRevisionNumber(r)) { + // only check key revisions (and final outcome), for speed + if (oldPad.getInternalRevisionText(r) != origText) { + var expected = oldPad.getInternalRevisionText(r); + var actual = origText; + //_putFile(expected, "/tmp/file1"); + //_putFile(actual, "/tmp/file2"); + //_putFile(oldText, "/tmp/file3"); + //java.lang.System.out.println(String(oldCs)); + //java.lang.System.out.println(easysync2.Changeset.toBaseTen(newCs)); + throw new Error("Migration mismatch, pad "+padId+", revision "+r); + } + } + + //S newTotalChangesetSize += stringSize(newCs); + + //P if (r > 0) { + //P var diffs = []; + //P for(var i=0;i<times.length-1;i++) { + //P diffs[i] = times[i+1] - times[i]; + //P } + //P for(var i=0;i<diffs.length;i++) { + //P diffTotals[i] = (diffTotals[i] || 0) + diffs[i]*1000/headRev; + //P } + //P } + } + //P System.out.println(String(diffTotals)); + + //S System.out.println("New data is "+(newTotalChangesetSize/oldTotalChangesetSize*100)+ + //S "% size of old data (average "+(newTotalChangesetSize/(headRev+1))+ + //S " bytes instead of "+(oldTotalChangesetSize/(headRev+1))+")"); + + var atext = easysync2.Changeset.makeAText("\n"); + for(var r=0; r<=headRev; r++) { + newPad.setRevsArrayEntry(r, newChangesets[r]); + + atext = easysync2.Changeset.applyToAText(newChangesets[r], atext, pool); + + var rm = oldPad.getRevMetaArrayEntry(r); + rm.a = newChangesetAuthorNums[r]; + if (rm.atext) { + rm.atext = easysync2.Changeset.cloneAText(atext); + } + newPad.setRevMetaArrayEntry(r, rm); + } + + var newAuthors = []; + var newAuthorDatas = []; + for(var k in oldPad._meta.numToAuthor) { + var n = Number(k); + var authorData = oldPad.getAuthorArrayEntry(n) || {}; + var authorName = oldPad._meta.numToAuthor[n]; + var newAuthorNum = pool.putAttrib(['author',authorName]); + newPad.setAuthorArrayEntry(newAuthorNum, authorData); + } + + newPad.deleteMetaProp('numToAuthor'); + newPad.deleteMetaProp('authorToNum'); + + mpad.writeToDB(); +} + +function getInitialChangeset(txt, pool, author) { + var txt2 = txt.substring(0, txt.length-1); // strip off final newline + + var assem = easysync2.Changeset.smartOpAssembler(); + assem.appendOpWithText('+', txt2, pool && author && [['author',author]], pool); + assem.endDocument(); + return easysync2.Changeset.pack(1, txt2.length+1, assem.toString(), txt2); +} + +function upgradeChangeset(cs, inputText, pool, author, isExtraNewlineInSource) { + var attribs = ''; + if (pool && author) { + attribs = '*'+easysync2.Changeset.numToString(pool.putAttrib(['author', author])); + } + + function keepLastCharacter(c) { + if (! c[c.length-1] && c[c.length-3] + c[c.length-2] >= (c.oldLen() - 1)) { + c[c.length-2] = c.oldLen() - c[c.length-3]; + } + else { + c.push(c.oldLen() - 1, 1, ""); + } + } + + var isExtraNewlineInOutput = false; + if (isExtraNewlineInSource) { + cs[1] += 1; // oldLen ++ + } + if ((cs[cs.length-1] && cs[cs.length-1].slice(-1) != '\n') || + ((! cs[cs.length-1]) && inputText.charAt(cs[cs.length-3] + cs[cs.length-2] - 1) != '\n')) { + // new text won't end with newline! + if (isExtraNewlineInSource) { + keepLastCharacter(cs); + } + else { + cs[cs.length-1] += "\n"; + } + cs[2] += 1; // newLen ++ + isExtraNewlineInOutput = true; + } + + var oldLen = cs.oldLen(); + var newLen = cs.newLen(); + + // final-newline-preserving modifications to changeset {{{ + // These fixes are required for changesets that don't respect the + // new rule that the final newline of the document not be touched, + // and also for changesets tweaked above. It is important that the + // fixed changesets obey all the constraints on version 1 changesets + // so that they may become valid version 2 changesets. + { + function collapsePotentialEmptyLastTake(c) { + if (c[c.length-2] == 0 && c.length > 6) { + if (! c[c.length-1]) { + // last strip doesn't take or insert now + c.length -= 3; + } + else { + // the last two strips should be merged + // e.g. fo\n -> rock\nbar\n: then in this block, + // "Changeset,3,9,0,0,r,1,1,ck,2,0,\nbar" becomes + // "Changeset,3,9,0,0,r,1,1,ck\nbar" + c[c.length-4] += c[c.length-1]; + c.length -= 3; + } + } + } + var lastStripStart = cs[cs.length-3]; + var lastStripTake = cs[cs.length-2]; + var lastStripInsert = cs[cs.length-1]; + if (lastStripStart + lastStripTake == oldLen && lastStripInsert) { + // an insert at end + // e.g. foo\n -> foo\nbar\n: + // "Changeset,4,8,0,4,bar\n" becomes "Changeset,4,8,0,3,\nbar,3,1," + // first make the previous newline part of the insertion + cs[cs.length-2] -= 1; + cs[cs.length-1] = '\n'+cs[cs.length-1].slice(0,-1); + collapsePotentialEmptyLastTake(cs); + keepLastCharacter(cs); + } + else if (lastStripStart + lastStripTake < oldLen && ! lastStripInsert) { + // ends with pure deletion + cs[cs.length-2] -= 1; + collapsePotentialEmptyLastTake(cs); + keepLastCharacter(cs); + } + else if (lastStripStart + lastStripTake < oldLen) { + // ends with replacement + cs[cs.length-1] = cs[cs.length-1].slice(0,-1); + keepLastCharacter(cs); + } + } + // }}} + + var ops = []; + var lastOpcode = ''; + function appendOp(opcode, text, startChar, endChar) { + function num(n) { + return easysync2.Changeset.numToString(n); + } + var lines = 0; + var lastNewlineEnd = startChar; + for (;;) { + var index = text.indexOf('\n', lastNewlineEnd); + if (index < 0 || index >= endChar) { + break; + } + lines++; + lastNewlineEnd = index+1; + } + var a = (opcode == '+' ? attribs : ''); + var multilineChars = (lastNewlineEnd - startChar); + var seqLength = endChar - startChar; + var op = ''; + if (lines > 0) { + op = [a, '|', num(lines), opcode, num(multilineChars)].join(''); + } + if (multilineChars < seqLength) { + op += [a, opcode, num(seqLength - multilineChars)].join(''); + } + if (op) { + // we reorder a single - and a single + + if (opcode == '-' && lastOpcode == '+') { + ops.splice(ops.length-1, 0, op); + } + else { + ops.push(op); + lastOpcode = opcode; + } + } + } + + var oldPos = 0; + + var textPieces = []; + var charBankPieces = []; + cs.eachStrip(function(start, take, insert) { + if (start > oldPos) { + appendOp('-', inputText, oldPos, start); + } + if (take) { + if (start+take < oldLen || insert) { + appendOp('=', inputText, start, start+take); + } + textPieces.push(inputText.substring(start, start+take)); + } + if (insert) { + appendOp('+', insert, 0, insert.length); + textPieces.push(insert); + charBankPieces.push(insert); + } + oldPos = start+take; + }); + // ... and no final deletions after the newline fixing. + + var newCs = easysync2.Changeset.pack(oldLen, newLen, ops.join(''), + sanitizeUnicode(charBankPieces.join(''))); + var newText = textPieces.join(''); + + return [newCs, newText, isExtraNewlineInOutput]; +} + +//////////////////////////////////////////////////////////////////////////////// + +// unicode issues: 5SaYQp7cKV + +// // hard-coded just for testing; any pad is allowed to have corruption. +// var newlineCorruptedPads = [ +// '0OCGFKkjDv', '14dWjOiOxP', '1LL8XQCBjC', '1jMnjEEK6e', '21', +// '23DytOPN7d', '32YzfdT2xS', '3E6GB7l7FZ', '3Un8qaCfJh', '3YAj3rC9em', +// '3vY2eaHSw5', '4834RRTLlg', '4Fm1iVSTWI', '5NpTNqWHGC', '7FYNSdYQVa', +// '7RZCbvgw1z', '8EVpyN6HyY', '8P5mPRxPVr', '8aHFRmLxKR', '8dsj9eGQfP', +// 'BSoGobOJZZ', 'Bf0uVghKy0', 'C2f3umStKd', 'CHlu2CA8F3', 'D2WEwgvg1W', +// 'DNLTpuP2wl', 'DwNpm2TDgu', 'EKPByZ3EGZ', 'FwQxu6UKQx', 'HUn9O34rFl', +// 'JKZhxMo20E', 'JVjuukL42N', 'JVuBlWxaxL', 'Jmw5lPNYcl', 'KnZHz6jE2P', +// 'Luyp6ylbgR', 'MB6lPoN1eI', 'McsCrQUM6c', 'NWIuVobIw9', 'OKERTLQCCn', +// 'OchiOchi', 'OfhKHCB8jJ', 'OkM3Jv3XY9', 'PX5Z89mx29', 'PdmKQIvOEd', +// 'R9NQNB66qt', 'RvULFSvCbV', 'RyLJC6Qo1x', 'SBlKLwr2Ag', 'SavD72Q9P7', +// 'SfXyxseAeF', 'TTGZ4yO2PI', 'U3U7rT3d6w', 'UFmqpQIDAi', 'V7Or0QQk4m', +// 'VPCM5ReAQm', 'VvIYHzIJUY', 'W0Ccc3BVGb', 'Wv3cGgSgjg', 'WwVPgaZUK5', +// 'WyIFUJXfm5', 'XxESEsgQ6R', 'Yc5Yq3WCuU', 'ZRqCFaRx6h', 'ZepX6TLFbD', +// 'bSeImT5po4', 'bqIlTkFDiH', 'btt9vNPSQ9', 'c97YJj8PSN', 'd9YV3sypKF', +// 'eDzzkrwDRU', 'eFQJZWclzo', 'eaz44OhFDu', 'ehKkx1YpLA', 'ep', +// 'foNq3v3e9T', 'form6rooma', 'fqhtIHG0Ii', 'fvZyCRZjv2', 'gZnadICPYV', +// 'gvGXtMKhQk', 'h7AYuTxUOd', 'hc1UZSti3J', 'hrFQtae2jW', 'i8rENUZUMu', +// 'iFW9dceEmh', 'iRNEc8SlOc', 'jEDsDgDlaK', 'jo8ngXlSJh', 'kgJrB9Gh2M', +// 'klassennetz76da2661f8ceccfe74faf97d25a4b418', +// 'klassennetzf06d4d8176d0804697d9650f836cb1f7', 'lDHgmfyiSu', +// 'mA1cbvxFwA', 'mSJpW1th29', 'mXHAqv1Emu', 'monocles12', 'n0NhU3FxxT', +// 'ng7AlzPb5b', 'ntbErnnuyz', 'oVnMO0dX80', 'omOTPVY3Gl', 'p5aNFCfYG9', +// 'pYxjVCILuL', 'phylab', 'pjVBFmnhf1', 'qGohFW3Lbr', 'qYlbjeIHDs', +// 'qgf4OwkFI6', 'qsi', 'rJQ09pRexM', 'snNjlS1aLC', 'tYKC53TDF9', +// 'u1vZmL8Yjv', 'ur4sb7DBJB', 'vesti', 'w9NJegEAZt', 'wDwlSCby2s', +// 'wGFJJRT514', 'wTgEoQGqng', 'xomMZGhius', 'yFEFYWBSvr', 'z7tGFKsGk6', +// 'zIJWNK8Z4i', 'zNMGJYI7hq']; + +// function _time(f) { +// var t1 = +(new Date); +// f(); +// var t2 = +(new Date); +// return t2 - t1; +// } + +// function listAllRevisionCounts() { +// var padList = sqlbase.getAllJSONKeys("PAD_META"); +// //padList.length = 10; +// padList = padList.slice(68000, 68100); +// padList.forEach(function(id) { +// model.accessPadGlobal(id, function(pad) { +// System.out.println((new java.lang.Integer(pad.getHeadRevisionNumber()).toString())+ +// " "+id); +// dbwriter.writePadNow(pad, true); +// }, 'r'); +// }); +// } + +// function verifyAllPads() { +// //var padList = sqlbase.getAllJSONKeys("PAD_META"); +// //padList = newlineCorruptedPads; +// var padList = ['0OCGFKkjDv']; +// //padList = ['form6rooma']; +// //padList.length = 10; +// var numOks = 0; +// var numErrors = 0; +// var numNewlineBugs = 0; +// var longestPad; +// var longestPadTime = -1; +// System.out.println(padList.length+" pads."); +// var totalTime = _time(function() { +// padList.forEach(function(id) { +// model.accessPadGlobal(id, function(pad) { +// var padTime = _time(function() { +// System.out.print(id+"... "); +// try { +// verifyMigration(pad); +// System.out.println("OK ("+(++numOks)+")"); +// } +// catch (e) { +// System.out.println("ERROR ("+(++numErrors)+")"+(e.finalNewlineMissing?" [newline]":"")); +// System.out.println(e.toString()); +// if (e.finalNewlineMissing) { +// numNewlineBugs++; +// } +// } +// }); +// if (padTime > longestPadTime) { +// longestPadTime = padTime; +// longestPad = id; +// } +// }, 'r'); +// }); +// }); +// System.out.println("finished verifyAllPads in "+(totalTime/1000)+" seconds."); +// System.out.println(numOks+" OK"); +// System.out.println(numErrors+" ERROR"); +// System.out.println("Most time-consuming pad: "+longestPad+" / "+longestPadTime+" ms"); +// } + +// function _literal(v) { +// if ((typeof v) == "string") { +// return '"'+v.replace(/[\\\"]/g, '\\$1').replace(/\n/g, '\\n')+'"'; +// } +// else return v.toSource(); +// } + +// function _putFile(str, path) { +// var writer = new java.io.FileWriter(path); +// writer.write(str); +// writer.close(); +// } diff --git a/trunk/etherpad/src/etherpad/pad/exporthtml.js b/trunk/etherpad/src/etherpad/pad/exporthtml.js new file mode 100644 index 0000000..2512603 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/exporthtml.js @@ -0,0 +1,383 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("etherpad.collab.ace.easysync2.Changeset"); + +function getPadPlainText(pad, revNum) { + var atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(revNum) : + pad.atext()); + var textLines = atext.text.slice(0,-1).split('\n'); + var attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); + var apool = pad.pool(); + + var pieces = []; + for(var i=0;i<textLines.length;i++) { + var line = _analyzeLine(textLines[i], attribLines[i], apool); + if (line.listLevel) { + var numSpaces = line.listLevel*2-1; + var bullet = '*'; + pieces.push(new Array(numSpaces+1).join(' '), bullet, ' ', line.text, '\n'); + } + else { + pieces.push(line.text, '\n'); + } + } + + return pieces.join(''); +} + +function getPadHTML(pad, revNum) { + var atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(revNum) : + pad.atext()); + var textLines = atext.text.slice(0,-1).split('\n'); + var attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); + + var apool = pad.pool(); + + var tags = ['b','i','u','s','h1','h2','h3','h4','h5','h6']; + var props = ['bold','italic','underline','strikethrough','h1','h2','h3','h4','h5','h6']; + var anumMap = {}; + props.forEach(function(propName, i) { + var propTrueNum = apool.putAttrib([propName,true], true); + if (propTrueNum >= 0) { + anumMap[propTrueNum] = i; + } + }); + + function getLineHTML(text, attribs) { + var propVals = [false, false, false]; + var ENTER = 1; + var STAY = 2; + var LEAVE = 0; + + // Use order of tags (b/i/u) as order of nesting, for simplicity + // and decent nesting. For example, + // <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i> + // becomes + // <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i> + + var taker = Changeset.stringIterator(text); + var assem = Changeset.stringAssembler(); + + function emitOpenTag(i) { + assem.append('<'); + assem.append(tags[i]); + assem.append('>'); + } + function emitCloseTag(i) { + assem.append('</'); + assem.append(tags[i]); + assem.append('>'); + } + + var urls = _findURLs(text); + + var idx = 0; + function processNextChars(numChars) { + if (numChars <= 0) { + return; + } + + var iter = Changeset.opIterator(Changeset.subattribution(attribs, + idx, idx+numChars)); + idx += numChars; + + while (iter.hasNext()) { + var o = iter.next(); + var propChanged = false; + Changeset.eachAttribNumber(o.attribs, function(a) { + if (a in anumMap) { + var i = anumMap[a]; // i = 0 => bold, etc. + if (! propVals[i]) { + propVals[i] = ENTER; + propChanged = true; + } + else { + propVals[i] = STAY; + } + } + }); + for(var i=0;i<propVals.length;i++) { + if (propVals[i] === true) { + propVals[i] = LEAVE; + propChanged = true; + } + else if (propVals[i] === STAY) { + propVals[i] = true; // set it back + } + } + // now each member of propVal is in {false,LEAVE,ENTER,true} + // according to what happens at start of span + + if (propChanged) { + // leaving bold (e.g.) also leaves italics, etc. + var left = false; + for(var i=0;i<propVals.length;i++) { + var v = propVals[i]; + if (! left) { + if (v === LEAVE) { + left = true; + } + } + else { + if (v === true) { + propVals[i] = STAY; // tag will be closed and re-opened + } + } + } + + for(var i=propVals.length-1; i>=0; i--) { + if (propVals[i] === LEAVE) { + emitCloseTag(i); + propVals[i] = false; + } + else if (propVals[i] === STAY) { + emitCloseTag(i); + } + } + for(var i=0; i<propVals.length; i++) { + if (propVals[i] === ENTER || propVals[i] === STAY) { + emitOpenTag(i); + propVals[i] = true; + } + } + // propVals is now all {true,false} again + } // end if (propChanged) + + var chars = o.chars; + if (o.lines) { + chars--; // exclude newline at end of line, if present + } + var s = taker.take(chars); + + assem.append(_escapeHTML(s)); + } // end iteration over spans in line + + for(var i=propVals.length-1; i>=0; i--) { + if (propVals[i]) { + emitCloseTag(i); + propVals[i] = false; + } + } + } // end processNextChars + + if (urls) { + urls.forEach(function(urlData) { + var startIndex = urlData[0]; + var url = urlData[1]; + var urlLength = url.length; + processNextChars(startIndex - idx); + assem.append('<a href="'+url.replace(/\"/g, '"')+'">'); + processNextChars(urlLength); + assem.append('</a>'); + }); + } + processNextChars(text.length - idx); + + return _processSpaces(assem.toString()); + } // end getLineHTML + + var pieces = []; + + // Need to deal with constraints imposed on HTML lists; can + // only gain one level of nesting at once, can't change type + // mid-list, etc. + // People might use weird indenting, e.g. skip a level, + // so we want to do something reasonable there. We also + // want to deal gracefully with blank lines. + var lists = []; // e.g. [[1,'bullet'], [3,'bullet'], ...] + for(var i=0;i<textLines.length;i++) { + var line = _analyzeLine(textLines[i], attribLines[i], apool); + var lineContent = getLineHTML(line.text, line.aline); + + if (line.listLevel || lists.length > 0) { + // do list stuff + var whichList = -1; // index into lists or -1 + if (line.listLevel) { + whichList = lists.length; + for(var j=lists.length-1;j>=0;j--) { + if (line.listLevel <= lists[j][0]) { + whichList = j; + } + } + } + + if (whichList >= lists.length) { + lists.push([line.listLevel, line.listTypeName]); + pieces.push('<ul><li>', lineContent || '<br/>'); + } + else if (whichList == -1) { + if (line.text) { + // non-blank line, end all lists + pieces.push(new Array(lists.length+1).join('</li></ul\n>')); + lists.length = 0; + pieces.push(lineContent, '<br\n/>'); + } + else { + pieces.push('<br/><br\n/>'); + } + } + else { + while (whichList < lists.length-1) { + pieces.push('</li></ul\n>'); + lists.length--; + } + pieces.push('</li\n><li>', lineContent || '<br/>'); + } + } + else { + pieces.push(lineContent, '<br\n/>'); + } + } + pieces.push(new Array(lists.length+1).join('</li></ul\n>')); + + return pieces.join(''); +} + +function _analyzeLine(text, aline, apool) { + var line = {}; + + // identify list + var lineMarker = 0; + line.listLevel = 0; + if (aline) { + var opIter = Changeset.opIterator(aline); + if (opIter.hasNext()) { + var listType = Changeset.opAttributeValue(opIter.next(), 'list', apool); + if (listType) { + lineMarker = 1; + listType = /([a-z]+)([12345678])/.exec(listType); + if (listType) { + line.listTypeName = listType[1]; + line.listLevel = Number(listType[2]); + } + } + } + } + if (lineMarker) { + line.text = text.substring(1); + line.aline = Changeset.subattribution(aline, 1); + } + else { + line.text = text; + line.aline = aline; + } + + return line; +} + +function getPadHTMLDocument(pad, revNum, noDocType) { + var head = (noDocType?'':'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" '+ + '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n')+ + '<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">\n'+ + (noDocType?'': + '<head>\n'+ + '<meta http-equiv="Content-type" content="text/html; charset=utf-8" />\n'+ + '<meta http-equiv="Content-Language" content="en-us" />\n'+ + '<title>'+'/'+pad.getId()+'</title>\n'+ + '<style type="text/css">h1,h2,h3,h4,h5,h6 { display: inline; }</style>\n' + + '</head>\n')+ + '<body>'; + + var foot = '</body>\n</html>\n'; + + return head + getPadHTML(pad, revNum) + foot; +} + +function _escapeHTML(s) { + var re = /[&<>]/g; + if (! re.MAP) { + // persisted across function calls! + re.MAP = { + '&': '&', + '<': '<', + '>': '>', + }; + } + return s.replace(re, function(c) { return re.MAP[c]; }); +} + +// copied from ACE +function _processSpaces(s) { + var doesWrap = true; + if (s.indexOf("<") < 0 && ! doesWrap) { + // short-cut + return s.replace(/ /g, ' '); + } + var parts = []; + s.replace(/<[^>]*>?| |[^ <]+/g, function(m) { parts.push(m); }); + if (doesWrap) { + var endOfLine = true; + var beforeSpace = false; + // last space in a run is normal, others are nbsp, + // end of line is nbsp + for(var i=parts.length-1;i>=0;i--) { + var p = parts[i]; + if (p == " ") { + if (endOfLine || beforeSpace) + parts[i] = ' '; + endOfLine = false; + beforeSpace = true; + } + else if (p.charAt(0) != "<") { + endOfLine = false; + beforeSpace = false; + } + } + // beginning of line is nbsp + for(var i=0;i<parts.length;i++) { + var p = parts[i]; + if (p == " ") { + parts[i] = ' '; + break; + } + else if (p.charAt(0) != "<") { + break; + } + } + } + else { + for(var i=0;i<parts.length;i++) { + var p = parts[i]; + if (p == " ") { + parts[i] = ' '; + } + } + } + return parts.join(''); +} + + +// copied from ACE +var _REGEX_WORDCHAR = /[\u0030-\u0039\u0041-\u005A\u0061-\u007A\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF\u0100-\u1FFF\u3040-\u9FFF\uF900-\uFDFF\uFE70-\uFEFE\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFDC]/; +var _REGEX_SPACE = /\s/; +var _REGEX_URLCHAR = new RegExp('('+/[-:@a-zA-Z0-9_.,~%+\/\\?=&#;()$]/.source+'|'+_REGEX_WORDCHAR.source+')'); +var _REGEX_URL = new RegExp(/(?:(?:https?|s?ftp|ftps|file|smb|afp|nfs|(x-)?man|gopher|txmt):\/\/|mailto:)/.source+_REGEX_URLCHAR.source+'*(?![:.,;])'+_REGEX_URLCHAR.source, 'g'); + +// returns null if no URLs, or [[startIndex1, url1], [startIndex2, url2], ...] +function _findURLs(text) { + _REGEX_URL.lastIndex = 0; + var urls = null; + var execResult; + while ((execResult = _REGEX_URL.exec(text))) { + urls = (urls || []); + var startIndex = execResult.index; + var url = execResult[0]; + urls.push([startIndex, url]); + } + + return urls; +} diff --git a/trunk/etherpad/src/etherpad/pad/importhtml.js b/trunk/etherpad/src/etherpad/pad/importhtml.js new file mode 100644 index 0000000..4a48c6f --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/importhtml.js @@ -0,0 +1,230 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +jimport("org.ccil.cowan.tagsoup.Parser"); +jimport("org.ccil.cowan.tagsoup.PYXWriter"); +jimport("java.io.StringReader"); +jimport("java.io.StringWriter"); +jimport("org.xml.sax.InputSource"); + +import("etherpad.collab.ace.easysync2.{Changeset,AttribPool}"); +import("etherpad.collab.ace.contentcollector.makeContentCollector"); +import("etherpad.collab.collab_server"); + +function setPadHTML(pad, html) { + var atext = htmlToAText(html, pad.pool()); + collab_server.setPadAText(pad, atext); +} + +function _html2pyx(html) { + var p = new Parser(); + var w = new StringWriter(); + var h = new PYXWriter(w); + p.setContentHandler(h); + var s = new InputSource(); + s.setCharacterStream(new StringReader(html)); + p.parse(s); + return w.toString().replace(/\r\n|\r|\n/g, '\n'); +} + +function _htmlBody2js(html) { + var pyx = _html2pyx(html); + var plines = pyx.split("\n"); + + function pyxUnescape(s) { + return s.replace(/\\t/g, '\t').replace(/\\/g, '\\'); + } + var inAttrs = false; + + var nodeStack = []; + var topNode = {}; + + var bodyNode = {name:"body"}; + + plines.forEach(function(pline) { + var t = pline.charAt(0); + var v = pline.substring(1); + if (inAttrs && t != 'A') { + inAttrs = false; + } + if (t == '?') { /* ignore */ } + else if (t == '(') { + var newNode = {name: v}; + if (v.toLowerCase() == "body") { + bodyNode = newNode; + } + topNode.children = (topNode.children || []); + topNode.children.push(newNode); + nodeStack.push(topNode); + topNode = newNode; + inAttrs = true; + } + else if (t == 'A') { + var spaceIndex = v.indexOf(' '); + var key = v.substring(0, spaceIndex); + var value = pyxUnescape(v.substring(spaceIndex+1)); + topNode.attrs = (topNode.attrs || {}); + topNode.attrs['$'+key] = value; + } + else if (t == '-') { + if (v == "\\n") { + v = '\n'; + } + else { + v = pyxUnescape(v); + } + if (v) { + topNode.children = (topNode.children || []); + if (topNode.children.length > 0 && + ((typeof topNode.children[topNode.children.length-1]) == "string")) { + // coallesce + topNode.children.push(topNode.children.pop() + v); + } + else { + topNode.children.push(v); + } + } + } + else if (t == ')') { + topNode = nodeStack.pop(); + } + }); + + return bodyNode; +} + +function _trimDomNode(n) { + function isWhitespace(str) { + return /^\s*$/.test(str); + } + function trimBeginningOrEnd(n, endNotBeginning) { + var cc = n.children; + var backwards = endNotBeginning; + if (cc) { + var i = (backwards ? cc.length-1 : 0); + var done = false; + var hitActualText = false; + while (! done) { + if (! (backwards ? (i >= 0) : (i < cc.length-1))) { + done = true; + } + else { + var c = cc[i]; + if ((typeof c) == "string") { + if (! isWhitespace(c)) { + // actual text + hitActualText = true; + break; + } + else { + // whitespace + cc[i] = ''; + } + } + else { + // recurse + if (trimBeginningOrEnd(cc[i], endNotBeginning)) { + hitActualText = true; + break; + } + } + i += (backwards ? -1 : 1); + } + } + n.children = n.children.filter(function(x) { return !!x; }); + return hitActualText; + } + return false; + } + trimBeginningOrEnd(n, false); + trimBeginningOrEnd(n, true); +} + +function htmlToAText(html, apool) { + var body = _htmlBody2js(html); + _trimDomNode(body); + + var dom = { + isNodeText: function(n) { + return (typeof n) == "string"; + }, + nodeTagName: function(n) { + return ((typeof n) == "object") && n.name; + }, + nodeValue: function(n) { + return String(n); + }, + nodeNumChildren: function(n) { + return (((typeof n) == "object") && n.children && n.children.length) || 0; + }, + nodeChild: function(n, i) { + return (((typeof n) == "object") && n.children && n.children[i]) || null; + }, + nodeProp: function(n, p) { + return (((typeof n) == "object") && n.attrs && n.attrs[p]) || null; + }, + nodeAttr: function(n, a) { + return (((typeof n) == "object") && n.attrs && n.attrs[a]) || null; + }, + optNodeInnerHTML: function(n) { + return null; + } + } + + var cc = makeContentCollector(true, null, apool, dom); + for(var i=0; i<dom.nodeNumChildren(body); i++) { + var n = dom.nodeChild(body, i); + cc.collectContent(n); + } + cc.notifyNextNode(null); + var ccData = cc.finish(); + + var textLines = ccData.lines; + var attLines = ccData.lineAttribs; + for(var i=0;i<textLines.length;i++) { + var txt = textLines[i]; + if (txt == " " || txt == "\xa0") { + // space or nbsp all alone on a line, remove + textLines[i] = ""; + attLines[i] = ""; + } + } + + var text = textLines.join('\n')+'\n'; + var attribs = _joinLineAttribs(attLines); + var atext = Changeset.makeAText(text, attribs); + + return atext; +} + +function _joinLineAttribs(lineAttribs) { + var assem = Changeset.smartOpAssembler(); + + var newline = Changeset.newOp('+'); + newline.chars = 1; + newline.lines = 1; + + lineAttribs.forEach(function(aline) { + var iter = Changeset.opIterator(aline); + while (iter.hasNext()) { + assem.append(iter.next()); + } + assem.append(newline); + }); + + return assem.toString(); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/pad/model.js b/trunk/etherpad/src/etherpad/pad/model.js new file mode 100644 index 0000000..9424f10 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/model.js @@ -0,0 +1,651 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("fastJSON"); +import("sqlbase.sqlbase"); +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); +import("timer"); +import("sync"); + +import("etherpad.collab.ace.easysync2.{Changeset,AttribPool}"); +import("etherpad.log"); +import("etherpad.pad.padevents"); +import("etherpad.pad.padutils"); +import("etherpad.pad.dbwriter"); +import("etherpad.pad.pad_migrations"); +import("etherpad.pad.pad_security"); +import("etherpad.collab.collab_server"); +import("cache_utils.syncedWithCache"); +jimport("net.appjet.common.util.LimitedSizeMapping"); + +jimport("java.lang.System.out.println"); + +jimport("java.util.concurrent.ConcurrentHashMap"); +jimport("net.appjet.oui.GlobalSynchronizer"); +jimport("net.appjet.oui.exceptionlog"); + +function onStartup() { + appjet.cache.pads = {}; + appjet.cache.pads.meta = new ConcurrentHashMap(); + appjet.cache.pads.temp = new ConcurrentHashMap(); + appjet.cache.pads.revs = new ConcurrentHashMap(); + appjet.cache.pads.revs10 = new ConcurrentHashMap(); + appjet.cache.pads.revs100 = new ConcurrentHashMap(); + appjet.cache.pads.revs1000 = new ConcurrentHashMap(); + appjet.cache.pads.chat = new ConcurrentHashMap(); + appjet.cache.pads.revmeta = new ConcurrentHashMap(); + appjet.cache.pads.authors = new ConcurrentHashMap(); + appjet.cache.pads.apool = new ConcurrentHashMap(); +} + +var _JSON_CACHE_SIZE = 10000; + +// to clear: appjet.cache.padmodel.modelcache.map.clear() +function _getModelCache() { + return syncedWithCache('padmodel.modelcache', function(cache) { + if (! cache.map) { + cache.map = new LimitedSizeMapping(_JSON_CACHE_SIZE); + } + return cache.map; + }); +} + +function cleanText(txt) { + return txt.replace(/\r\n/g,'\n').replace(/\r/g,'\n').replace(/\t/g, ' ').replace(/\xa0/g, ' '); +} + +/** + * Access a pad object, which is passed as an argument to + * the given padFunc, which is executed inside an exclusive lock, + * and return the result. If the pad doesn't exist, a wrapper + * object is still created and passed to padFunc, and it can + * be used to check whether the pad exists and create it. + * + * Note: padId is a GLOBAL id. + */ +function accessPadGlobal(padId, padFunc, rwMode) { + // this may make a nested call to accessPadGlobal, so do it first + pad_security.checkAccessControl(padId, rwMode); + + // pad is never loaded into memory (made "active") unless it has been migrated. + // Migrations do not use accessPad, but instead access the database directly. + pad_migrations.ensureMigrated(padId); + + var mode = (rwMode || "rw").toLowerCase(); + + if (! appjet.requestCache.padsAccessing) { + appjet.requestCache.padsAccessing = {}; + } + if (appjet.requestCache.padsAccessing[padId]) { + // nested access to same pad + var p = appjet.requestCache.padsAccessing[padId]; + var m = p._meta; + if (m && mode != "r") { + m.status.lastAccess = +new Date(); + m.status.dirty = true; + } + return padFunc(p); + } + + return doWithPadLock(padId, function() { + return sqlcommon.inTransaction(function() { + var meta = _getPadMetaData(padId); // null if pad doesn't exist yet + + if (meta && ! meta.status) { + meta.status = { validated: false }; + } + + if (meta && mode != "r") { + meta.status.lastAccess = +new Date(); + } + + function getCurrentAText() { + var tempObj = pad.tempObj(); + if (! tempObj.atext) { + tempObj.atext = pad.getInternalRevisionAText(meta.head); + } + return tempObj.atext; + } + function addRevision(theChangeset, author, optDatestamp) { + var atext = getCurrentAText(); + var newAText = Changeset.applyToAText(theChangeset, atext, pad.pool()); + Changeset.copyAText(newAText, atext); // updates pad.tempObj().atext! + + var newRev = ++meta.head; + + var revs = _getPadStringArray(padId, "revs"); + revs.setEntry(newRev, theChangeset); + + var revmeta = _getPadStringArray(padId, "revmeta"); + var thisRevMeta = {t: (optDatestamp || (+new Date())), + a: getNumForAuthor(author)}; + if ((newRev % meta.keyRevInterval) == 0) { + thisRevMeta.atext = atext; + } + revmeta.setJSONEntry(newRev, thisRevMeta); + + updateCoarseChangesets(true); + } + function getNumForAuthor(author, dontAddIfAbsent) { + return pad.pool().putAttrib(['author',author||''], dontAddIfAbsent); + } + function getAuthorForNum(n) { + // must return null if n is an attrib number that isn't an author + var pair = pad.pool().getAttrib(n); + if (pair && pair[0] == 'author') { + return pair[1]; + } + return null; + } + + function updateCoarseChangesets(onlyIfPresent) { + // this is fast to run if the coarse changesets + // are up-to-date or almost up-to-date; + // if there's no coarse changeset data, + // it may take a while. + + if (! meta.coarseHeads) { + if (onlyIfPresent) { + return; + } + else { + meta.coarseHeads = {10:-1, 100:-1, 1000:-1}; + } + } + var head = meta.head; + // once we reach head==9, coarseHeads[10] moves + // from -1 up to 0; at head==19 it moves up to 1 + var desiredCoarseHeads = { + 10: Math.floor((head-9)/10), + 100: Math.floor((head-99)/100), + 1000: Math.floor((head-999)/1000) + }; + var revs = _getPadStringArray(padId, "revs"); + var revs10 = _getPadStringArray(padId, "revs10"); + var revs100 = _getPadStringArray(padId, "revs100"); + var revs1000 = _getPadStringArray(padId, "revs1000"); + var fineArrays = [revs, revs10, revs100]; + var coarseArrays = [revs10, revs100, revs1000]; + var levels = [10, 100, 1000]; + var dirty = false; + for(var z=0;z<3;z++) { + var level = levels[z]; + var coarseArray = coarseArrays[z]; + var fineArray = fineArrays[z]; + while (meta.coarseHeads[level] < desiredCoarseHeads[level]) { + dirty = true; + // for example, if the current coarse head is -1, + // compose 0-9 inclusive of the finer level and call it 0 + var x = meta.coarseHeads[level] + 1; + var cs = fineArray.getEntry(10 * x); + for(var i=1;i<=9;i++) { + cs = Changeset.compose(cs, fineArray.getEntry(10*x + i), + pad.pool()); + } + coarseArray.setEntry(x, cs); + meta.coarseHeads[level] = x; + } + } + if (dirty) { + meta.status.dirty = true; + } + } + + /////////////////// "Public" API starts here (functions used by collab_server or other modules) + var pad = { + // Operations that write to the data structure should + // set meta.dirty = true. Any pad access that isn't + // done in "read" mode also sets dirty = true. + getId: function() { return padId; }, + exists: function() { return !!meta; }, + create: function(optText) { + meta = {}; + meta.head = -1; // incremented below by addRevision + pad.tempObj().atext = Changeset.makeAText("\n"); + meta.padId = padId, + meta.keyRevInterval = 100; + meta.numChatMessages = 0; + var t = +new Date(); + meta.status = { validated: true }; + meta.status.lastAccess = t; + meta.status.dirty = true; + meta.supportsTimeSlider = true; + + var firstChangeset = Changeset.makeSplice("\n", 0, 0, + cleanText(optText || '')); + addRevision(firstChangeset, ''); + + _insertPadMetaData(padId, meta); + + sqlobj.insert("PAD_SQLMETA", { + id: padId, version: 2, creationTime: new Date(t), lastWriteTime: new Date(), + headRev: meta.head }); // headRev is not authoritative, just for info + + padevents.onNewPad(pad); + }, + destroy: function() { // you may want to collab_server.bootAllUsers first + padevents.onDestroyPad(pad); + + _destroyPadStringArray(padId, "revs"); + _destroyPadStringArray(padId, "revs10"); + _destroyPadStringArray(padId, "revs100"); + _destroyPadStringArray(padId, "revs1000"); + _destroyPadStringArray(padId, "revmeta"); + _destroyPadStringArray(padId, "chat"); + _destroyPadStringArray(padId, "authors"); + _removePadMetaData(padId); + _removePadAPool(padId); + sqlobj.deleteRows("PAD_SQLMETA", { id: padId }); + meta = null; + }, + writeToDB: function() { + var meta2 = {}; + for(var k in meta) meta2[k] = meta[k]; + delete meta2.status; + sqlbase.putJSON("PAD_META", padId, meta2); + + _getPadStringArray(padId, "revs").writeToDB(); + _getPadStringArray(padId, "revs10").writeToDB(); + _getPadStringArray(padId, "revs100").writeToDB(); + _getPadStringArray(padId, "revs1000").writeToDB(); + _getPadStringArray(padId, "revmeta").writeToDB(); + _getPadStringArray(padId, "chat").writeToDB(); + _getPadStringArray(padId, "authors").writeToDB(); + sqlbase.putJSON("PAD_APOOL", padId, pad.pool().toJsonable()); + + var props = { headRev: meta.head, lastWriteTime: new Date() }; + _writePadSqlMeta(padId, props); + }, + pool: function() { + return _getPadAPool(padId); + }, + getHeadRevisionNumber: function() { return meta.head; }, + getRevisionAuthor: function(r) { + var n = _getPadStringArray(padId, "revmeta").getJSONEntry(r).a; + return getAuthorForNum(Number(n)); + }, + getRevisionChangeset: function(r) { + return _getPadStringArray(padId, "revs").getEntry(r); + }, + tempObj: function() { return _getPadTemp(padId); }, + getKeyRevisionNumber: function(r) { + return Math.floor(r / meta.keyRevInterval) * meta.keyRevInterval; + }, + getInternalRevisionAText: function(r) { + var cacheKey = "atext/C/"+r+"/"+padId; + var modelCache = _getModelCache(); + var cachedValue = modelCache.get(cacheKey); + if (cachedValue) { + modelCache.touch(cacheKey); + //java.lang.System.out.println("HIT! "+cacheKey); + return Changeset.cloneAText(cachedValue); + } + //java.lang.System.out.println("MISS! "+cacheKey); + + var revs = _getPadStringArray(padId, "revs"); + var keyRev = pad.getKeyRevisionNumber(r); + var revmeta = _getPadStringArray(padId, "revmeta"); + var atext = revmeta.getJSONEntry(keyRev).atext; + var curRev = keyRev; + var targetRev = r; + var apool = pad.pool(); + while (curRev < targetRev) { + curRev++; + var cs = pad.getRevisionChangeset(curRev); + atext = Changeset.applyToAText(cs, atext, apool); + } + modelCache.put(cacheKey, Changeset.cloneAText(atext)); + return atext; + }, + getInternalRevisionText: function(r, optInfoObj) { + var atext = pad.getInternalRevisionAText(r); + var text = atext.text; + if (optInfoObj) { + if (text.slice(-1) != "\n") { + optInfoObj.badLastChar = text.slice(-1); + } + } + return text; + }, + getRevisionText: function(r, optInfoObj) { + var internalText = pad.getInternalRevisionText(r, optInfoObj); + return internalText.slice(0, -1); + }, + atext: function() { return Changeset.cloneAText(getCurrentAText()); }, + text: function() { return pad.atext().text; }, + getRevisionDate: function(r) { + var revmeta = _getPadStringArray(padId, "revmeta"); + return new Date(revmeta.getJSONEntry(r).t); + }, + // note: calls like appendRevision will NOT notify clients of the change! + // you must go through collab_server. + // Also, be sure to run cleanText() on any text to strip out carriage returns + // and other stuff. + appendRevision: function(theChangeset, author, optDatestamp) { + addRevision(theChangeset, author || '', optDatestamp); + }, + appendChatMessage: function(obj) { + var index = meta.numChatMessages; + meta.numChatMessages++; + var chat = _getPadStringArray(padId, "chat"); + chat.setJSONEntry(index, obj); + }, + getNumChatMessages: function() { + return meta.numChatMessages; + }, + getChatMessage: function(i) { + var chat = _getPadStringArray(padId, "chat"); + return chat.getJSONEntry(i); + }, + getPadOptionsObj: function() { + var data = pad.getDataRoot(); + if (! data.padOptions) { + data.padOptions = {}; + } + if ((! data.padOptions.guestPolicy) || + (data.padOptions.guestPolicy == 'ask')) { + data.padOptions.guestPolicy = 'deny'; + } + return data.padOptions; + }, + getGuestPolicy: function() { + // allow/ask/deny + return pad.getPadOptionsObj().guestPolicy; + }, + setGuestPolicy: function(policy) { + pad.getPadOptionsObj().guestPolicy = policy; + }, + getDataRoot: function() { + var dataRoot = meta.dataRoot; + if (! dataRoot) { + dataRoot = {}; + meta.dataRoot = dataRoot; + } + return dataRoot; + }, + // returns an object, changes to which are not reflected + // in the DB; use setAuthorData for mutation + getAuthorData: function(author) { + var authors = _getPadStringArray(padId, "authors"); + var n = getNumForAuthor(author, true); + if (n < 0) { + return null; + } + else { + return authors.getJSONEntry(n); + } + }, + setAuthorData: function(author, data) { + var authors = _getPadStringArray(padId, "authors"); + var n = getNumForAuthor(author); + authors.setJSONEntry(n, data); + }, + adoptChangesetAttribs: function(cs, oldPool) { + return Changeset.moveOpsToNewPool(cs, oldPool, pad.pool()); + }, + eachATextAuthor: function(atext, func) { + var seenNums = {}; + Changeset.eachAttribNumber(atext.attribs, function(n) { + if (! seenNums[n]) { + seenNums[n] = true; + var author = getAuthorForNum(n); + if (author) { + func(author, n); + } + } + }); + }, + getCoarseChangeset: function(start, numChangesets) { + updateCoarseChangesets(); + + if (!(numChangesets == 10 || numChangesets == 100 || + numChangesets == 1000)) { + return null; + } + var level = numChangesets; + var x = Math.floor(start / level); + if (!(x >= 0 && x*level == start)) { + return null; + } + + var cs = _getPadStringArray(padId, "revs"+level).getEntry(x); + + if (! cs) { + return null; + } + + return cs; + }, + getSupportsTimeSlider: function() { + if (! ('supportsTimeSlider' in meta)) { + if (padutils.isProPadId(padId)) { + return true; + } + else { + return false; + } + } + else { + return !! meta.supportsTimeSlider; + } + }, + setSupportsTimeSlider: function(v) { + meta.supportsTimeSlider = v; + }, + get _meta() { return meta; } + }; + + try { + padutils.setCurrentPad(padId); + appjet.requestCache.padsAccessing[padId] = pad; + return padFunc(pad); + } + finally { + padutils.clearCurrentPad(); + delete appjet.requestCache.padsAccessing[padId]; + if (meta) { + if (mode != "r") { + meta.status.dirty = true; + } + if (meta.status.dirty) { + dbwriter.notifyPadDirty(padId); + } + } + } + }); + }); +} + +/** + * Call an arbitrary function with no arguments inside an exclusive + * lock on a padId, and return the result. + */ +function doWithPadLock(padId, func) { + var lockName = "document/"+padId; + return sync.doWithStringLock(lockName, func); +} + +function isPadLockHeld(padId) { + var lockName = "document/"+padId; + return GlobalSynchronizer.isHeld(lockName); +} + +/** + * Get pad meta-data object, which is stored in SQL as JSON + * but cached in appjet.cache. Returns null if pad doesn't + * exist at all (does NOT create it). Requires pad lock. + */ +function _getPadMetaData(padId) { + var padMeta = appjet.cache.pads.meta.get(padId); + if (! padMeta) { + // not in cache + padMeta = sqlbase.getJSON("PAD_META", padId); + if (! padMeta) { + // not in SQL + padMeta = null; + } + else { + appjet.cache.pads.meta.put(padId, padMeta); + } + } + return padMeta; +} + +/** + * Sets a pad's meta-data object, such as when creating + * a pad for the first time. Requires pad lock. + */ +function _insertPadMetaData(padId, obj) { + appjet.cache.pads.meta.put(padId, obj); +} + +/** + * Removes a pad's meta data, writing through to the database. + * Used for the rare case of deleting a pad. + */ +function _removePadMetaData(padId) { + appjet.cache.pads.meta.remove(padId); + sqlbase.deleteJSON("PAD_META", padId); +} + +function _getPadAPool(padId) { + var padAPool = appjet.cache.pads.apool.get(padId); + if (! padAPool) { + // not in cache + padAPool = new AttribPool(); + padAPoolJson = sqlbase.getJSON("PAD_APOOL", padId); + if (padAPoolJson) { + // in SQL + padAPool.fromJsonable(padAPoolJson); + } + appjet.cache.pads.apool.put(padId, padAPool); + } + return padAPool; +} + +/** + * Removes a pad's apool data, writing through to the database. + * Used for the rare case of deleting a pad. + */ +function _removePadAPool(padId) { + appjet.cache.pads.apool.remove(padId); + sqlbase.deleteJSON("PAD_APOOL", padId); +} + +/** + * Get an object for a pad that's not persisted in storage, + * e.g. for tracking open connections. Creates object + * if necessary. Requires pad lock. + */ +function _getPadTemp(padId) { + var padTemp = appjet.cache.pads.temp.get(padId); + if (! padTemp) { + padTemp = {}; + appjet.cache.pads.temp.put(padId, padTemp); + } + return padTemp; +} + +/** + * Returns an object with methods for manipulating a string array, where name + * is something like "revs" or "chat". The object must be acquired and used + * all within a pad lock. + */ +function _getPadStringArray(padId, name) { + var padFoo = appjet.cache.pads[name].get(padId); + if (! padFoo) { + padFoo = {}; + // writes go into writeCache, which is authoritative for reads; + // reads cause pages to be read into readCache + padFoo.readCache = {}; + padFoo.writeCache = {}; + appjet.cache.pads[name].put(padId, padFoo); + } + var tableName = "PAD_"+name.toUpperCase(); + var self = { + getEntry: function(idx) { + var n = Number(idx); + if (padFoo.writeCache[n]) return padFoo.writeCache[n]; + if (padFoo.readCache[n]) return padFoo.readCache[n]; + sqlbase.getPageStringArrayElements(tableName, padId, n, padFoo.readCache); + return padFoo.readCache[n]; // null if not present in SQL + }, + setEntry: function(idx, value) { + var n = Number(idx); + var v = String(value); + padFoo.writeCache[n] = v; + }, + getJSONEntry: function(idx) { + var result = self.getEntry(idx); + if (! result) return result; + return fastJSON.parse(String(result)); + }, + setJSONEntry: function(idx, valueObj) { + self.setEntry(idx, fastJSON.stringify(valueObj)); + }, + writeToDB: function() { + sqlbase.putDictStringArrayElements(tableName, padId, padFoo.writeCache); + // copy key-vals of writeCache into readCache + var readCache = padFoo.readCache; + var writeCache = padFoo.writeCache; + for(var p in writeCache) { + readCache[p] = writeCache[p]; + } + padFoo.writeCache = {}; + } + }; + return self; +} + +/** + * Destroy a string array; writes through to the database. Must be + * called within a pad lock. + */ +function _destroyPadStringArray(padId, name) { + appjet.cache.pads[name].remove(padId); + var tableName = "PAD_"+name.toUpperCase(); + sqlbase.clearStringArray(tableName, padId); +} + +/** + * SELECT the row of PAD_SQLMETA for the given pad. Requires pad lock. + */ +function _getPadSqlMeta(padId) { + return sqlobj.selectSingle("PAD_SQLMETA", { id: padId }); +} + +function _writePadSqlMeta(padId, updates) { + sqlobj.update("PAD_SQLMETA", { id: padId }, updates); +} + + +// called from dbwriter +function removeFromMemory(pad) { + // safe to call if all data is written to SQL, otherwise will lose data; + var padId = pad.getId(); + appjet.cache.pads.meta.remove(padId); + appjet.cache.pads.revs.remove(padId); + appjet.cache.pads.revs10.remove(padId); + appjet.cache.pads.revs100.remove(padId); + appjet.cache.pads.revs1000.remove(padId); + appjet.cache.pads.chat.remove(padId); + appjet.cache.pads.revmeta.remove(padId); + appjet.cache.pads.apool.remove(padId); + collab_server.removeFromMemory(pad); +} + + diff --git a/trunk/etherpad/src/etherpad/pad/noprowatcher.js b/trunk/etherpad/src/etherpad/pad/noprowatcher.js new file mode 100644 index 0000000..8eb2a92 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/noprowatcher.js @@ -0,0 +1,110 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + * noprowatcher keeps track of when a pad has had no pro user + * in it for a certain period of time, after which all guests + * are booted. + */ + +import("etherpad.pad.padutils"); +import("etherpad.collab.collab_server"); +import("etherpad.pad.padusers"); +import("etherpad.pad.pad_security"); +import("etherpad.pad.model"); +import("cache_utils.syncedWithCache"); +import("execution"); +import("etherpad.sessions"); + +function onStartup() { + execution.initTaskThreadPool("noprowatcher", 1); +} + +function getNumProUsers(pad) { + var n = 0; + collab_server.getConnectedUsers(pad).forEach(function(info) { + if (! padusers.isGuest(info.userId)) { + n++; // found a non-guest + } + }); + return n; +} + +var _EMPTY_TIME = 60000; + +function checkPad(padOrPadId) { + if ((typeof padOrPadId) == "string") { + return model.accessPadGlobal(padOrPadId, function(pad) { + return checkPad(pad); + }); + } + var pad = padOrPadId; + + if (! padutils.isProPad(pad)) { + return; // public pad + } + + if (pad.getGuestPolicy() == 'allow') { + return; // public access + } + + if (sessions.isAnEtherpadAdmin()) { + return; + } + + var globalPadId = pad.getId(); + + var numConnections = collab_server.getNumConnections(pad); + var numProUsers = getNumProUsers(pad); + syncedWithCache('noprowatcher.no_pros_since', function(noProsSince) { + if (! numConnections) { + // no connections, clear state and we're done + delete noProsSince[globalPadId]; + } + else if (numProUsers) { + // pro users in pad, so we're not in a span of time with + // no pro users + delete noProsSince[globalPadId]; + } + else { + // no pro users in pad + var since = noProsSince[globalPadId]; + if (! since) { + // no entry in cache, that means last time we checked + // there were still pro users, but now there aren't + noProsSince[globalPadId] = +new Date; + execution.scheduleTask("noprowatcher", "noProWatcherCheckPad", + _EMPTY_TIME+1000, [globalPadId]); + } + else { + // already in a span of time with no pro users + if ((+new Date) - since > _EMPTY_TIME) { + // _EMPTY_TIME milliseconds since we first noticed no pro users + collab_server.bootAllUsersFromPad(pad, "unauth"); + pad_security.revokeAllPadAccess(globalPadId); + } + } + } + }); +} + +function onUserJoin(pad, userInfo) { + checkPad(pad); +} + +function onUserLeave(pad, userInfo) { + checkPad(pad); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/pad/pad_migrations.js b/trunk/etherpad/src/etherpad/pad/pad_migrations.js new file mode 100644 index 0000000..e81cf63 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/pad_migrations.js @@ -0,0 +1,206 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("etherpad.pad.model"); +import("etherpad.pad.easysync2migration"); +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); +import("etherpad.log"); +import("etherpad.pne.pne_utils"); +jimport("java.util.concurrent.ConcurrentHashMap"); +jimport("java.lang.System"); +jimport("java.util.ArrayList"); +jimport("java.util.Collections"); + +function onStartup() { + if (! appjet.cache.pad_migrations) { + appjet.cache.pad_migrations = {}; + } + + // this part can be removed when all pads are migrated on pad.spline.inf.fu-berlin.de + //if (! pne_utils.isPNE()) { + // System.out.println("Building cache for live migrations..."); + // initLiveMigration(); + //} +} + +function initLiveMigration() { + + if (! appjet.cache.pad_migrations) { + appjet.cache.pad_migrations = {}; + } + appjet.cache.pad_migrations.doingAnyLiveMigrations = true; + appjet.cache.pad_migrations.doingBackgroundLiveMigrations = true; + appjet.cache.pad_migrations.padMap = new ConcurrentHashMap(); + + // presence of a pad in padMap indicates migration is needed + var padMap = _padMap(); + var migrationsNeeded = sqlobj.selectMulti("PAD_SQLMETA", {version: 1}); + migrationsNeeded.forEach(function(obj) { + padMap.put(String(obj.id), {from: obj.version}); + }); +} + +function _padMap() { + return appjet.cache.pad_migrations.padMap; +} + +function _doingItLive() { + return !! appjet.cache.pad_migrations.doingAnyLiveMigrations; +} + +function checkPadStatus(padId) { + if (! _doingItLive()) { + return "ready"; + } + var info = _padMap().get(padId); + if (! info) { + return "ready"; + } + else if (info.migrating) { + return "migrating"; + } + else { + return "oldversion"; + } +} + +function ensureMigrated(padId, async) { + if (! _doingItLive()) { + return false; + } + + var info = _padMap().get(padId); + if (! info) { + // pad is up-to-date + return false; + } + else if (async && info.migrating) { + // pad is already being migrated, don't wait on the lock + return false; + } + + return model.doWithPadLock(padId, function() { + // inside pad lock... + var info = _padMap().get(padId); + if (!info) { + return false; + } + // migrate from version 1 to version 2 in a transaction + var migrateSucceeded = false; + try { + info.migrating = true; + log.info("Migrating pad "+padId+" from version 1 to version 2..."); + + var success = false; + var whichTry = 1; + while ((! success) && whichTry <= 3) { + success = sqlcommon.inTransaction(function() { + try { + easysync2migration.migratePad(padId); + sqlobj.update("PAD_SQLMETA", {id: padId}, {version: 2}); + return true; + } + catch (e if (e.toString().indexOf("try restarting transaction") >= 0)) { + whichTry++; + return false; + } + }); + if (! success) { + java.lang.Thread.sleep(Math.floor(Math.random()*200)); + } + } + if (! success) { + throw new Error("too many retries"); + } + + migrateSucceeded = true; + log.info("Migrated pad "+padId+"."); + _padMap().remove(padId); + } + finally { + info.migrating = false; + if (! migrateSucceeded) { + log.info("Migration failed for pad "+padId+"."); + throw new Error("Migration failed for pad "+padId+"."); + } + } + return true; + }); +} + +function numUnmigratedPads() { + if (! _doingItLive()) { + return 0; + } + + return _padMap().size(); +} + +////////// BACKGROUND MIGRATIONS + +function _logPadMigration(runnerId, padNumber, padTotal, timeMs, fourCharResult, padId) { + log.custom("pad_migrations", { + runnerId: runnerId, + padNumber: Math.round(padNumber+1), + padTotal: Math.round(padTotal), + timeMs: Math.round(timeMs), + fourCharResult: fourCharResult, + padId: padId}); +} + +function _getNeededMigrationsArrayList(filter) { + var L = new ArrayList(_padMap().keySet()); + for(var i=L.size()-1; i>=0; i--) { + if (! filter(String(L.get(i)))) { + L.remove(i); + } + } + return L; +} + +function runBackgroundMigration(residue, modulus, runnerId) { + var L = _getNeededMigrationsArrayList(function(padId) { + return (padId.charCodeAt(0) % modulus) == residue; + }); + Collections.shuffle(L); + + var totalPads = L.size(); + for(var i=0;i<totalPads;i++) { + if (! appjet.cache.pad_migrations.doingBackgroundLiveMigrations) { + break; + } + var padId = L.get(i); + var result = "FAIL"; + var t1 = System.currentTimeMillis(); + try { + if (ensureMigrated(padId, true)) { + result = " OK "; // migrated successfully + } + else { + result = " -- "; // no migration needed after all + } + } + catch (e) { + // e just says "migration failed", but presumably + // inTransaction() printed a stack trace. + // result == "FAIL", do nothing. + } + var t2 = System.currentTimeMillis(); + _logPadMigration(runnerId, i, totalPads, t2 - t1, result, padId); + } +} diff --git a/trunk/etherpad/src/etherpad/pad/pad_security.js b/trunk/etherpad/src/etherpad/pad/pad_security.js new file mode 100644 index 0000000..0ff8783 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/pad_security.js @@ -0,0 +1,237 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("stringutils"); +import("cache_utils.syncedWithCache"); + +import("etherpad.sessions.getSession"); +import("etherpad.sessions"); + +import("etherpad.pad.model"); +import("etherpad.pad.padutils"); +import("etherpad.pad.padusers"); +import("etherpad.pro.domains"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_accounts.getSessionProAccount"); +import("etherpad.pro.pro_padmeta"); +import("etherpad.pro.pro_utils"); +import("etherpad.pro.pro_utils.isProDomainRequest"); +import("etherpad.pad.noprowatcher"); + +//-------------------------------------------------------------------------------- +// granting session permanent access to pads (for the session) +//-------------------------------------------------------------------------------- + +function _grantSessionAccessTo(globalPadId) { + var userId = padusers.getUserId(); + syncedWithCache("pad-auth."+globalPadId, function(c) { + c[userId] = true; + }); +} + +function _doesSessionHaveAccessTo(globalPadId) { + var userId = padusers.getUserId(); + return syncedWithCache("pad-auth."+globalPadId, function(c) { + return c[userId]; + }); +} + +function revokePadUserAccess(globalPadId, userId) { + syncedWithCache("pad-auth."+globalPadId, function(c) { + delete c[userId]; + }); +} + +function revokeAllPadAccess(globalPadId) { + syncedWithCache("pad-auth."+globalPadId, function(c) { + for (k in c) { + delete c[k]; + } + }); +} + +//-------------------------------------------------------------------------------- +// knock/answer +//-------------------------------------------------------------------------------- + +function clearKnockStatus(userId, globalPadId) { + syncedWithCache("pad-guest-knocks."+globalPadId, function(c) { + delete c[userId]; + }); +} + +// called by collab_server when accountholders approve or deny +function answerKnock(userId, globalPadId, status) { + // status is either "approved" or "denied" + syncedWithCache("pad-guest-knocks."+globalPadId, function(c) { + // If two account-holders respond to the knock, keep the first one. + if (!c[userId]) { + c[userId] = status; + } + }); +} + +// returns "approved", "denied", or undefined +function getKnockAnswer(userId, globalPadId) { + return syncedWithCache("pad-guest-knocks."+globalPadId, function(c) { + return c[userId]; + }); +} + +//-------------------------------------------------------------------------------- +// main entrypoint called for every accessPad() +//-------------------------------------------------------------------------------- + +var _insideCheckAccessControl = false; + +function checkAccessControl(globalPadId, rwMode) { + if (!request.isDefined) { + return; // TODO: is this the right thing to do here? + // Empirical evidence indicates request.isDefined during comet requests, + // but not during tasks, which is the behavior we want. + } + + if (_insideCheckAccessControl) { + // checkAccessControl is always allowed to access pads itself + return; + } + if (isProDomainRequest() && (request.path == "/ep/account/guest-knock")) { + return; + } + if (!isProDomainRequest() && (request.path == "/ep/admin/padinspector")) { + return; + } + if (isProDomainRequest() && (request.path == "/ep/padlist/all-pads.zip")) { + return; + } + try { + _insideCheckAccessControl = true; + + if (!padutils.isProPadId(globalPadId)) { + // no access control on non-pro pads yet. + return; + } + + if (sessions.isAnEtherpadAdmin()) { + return; + } + if (_doesSessionHaveAccessTo(globalPadId)) { + return; + } + _checkDomainSecurity(globalPadId); + _checkGuestSecurity(globalPadId); + _checkPasswordSecurity(globalPadId); + + // remember that this user has access + _grantSessionAccessTo(globalPadId); + } + finally { + // this always runs, even on error or stop + _insideCheckAccessControl = false; + } +} + +function _checkDomainSecurity(globalPadId) { + var padDomainId = padutils.getDomainId(globalPadId); + if (!padDomainId) { + return; // global pad + } + if (pro_utils.isProDomainRequest()) { + var requestDomainId = domains.getRequestDomainId(); + if (requestDomainId != padDomainId) { + throw Error("Request cross-domain pad access not allowed."); + } + } +} + +function _checkGuestSecurity(globalPadId) { + if (!getSession().guestPadAccess) { + getSession().guestPadAccess = {}; + } + + var padDomainId = padutils.getDomainId(globalPadId); + var isAccountHolder = pro_accounts.isAccountSignedIn(); + if (isAccountHolder) { + if (getSessionProAccount().domainId != padDomainId) { + throw Error("Account cross-domain pad access not allowed."); + } + return; // OK + } + + // Not an account holder ==> Guest + + // returns either "allow", "ask", or "deny" + var guestPolicy = model.accessPadGlobal(globalPadId, function(p) { + if (!p.exists()) { + return "deny"; + } else { + return p.getGuestPolicy(); + } + }); + + var numProUsers = model.accessPadGlobal(globalPadId, function(pad) { + return noprowatcher.getNumProUsers(pad); + }); + + if (guestPolicy == "allow") { + return; + } + if (guestPolicy == "deny") { + pro_accounts.requireAccount("Guests are not allowed to join that pad. Please sign in."); + } + if (guestPolicy == "ask") { + if (numProUsers < 1) { + pro_accounts.requireAccount("This pad's security policy does not allow guests to join unless an account-holder is connected to the pad."); + } + var userId = padusers.getUserId(); + + // one of {"approved", "denied", undefined} + var knockAnswer = getKnockAnswer(userId, globalPadId); + if (knockAnswer == "approved") { + return; + } else { + var localPadId = padutils.globalToLocalId(globalPadId); + response.redirect('/ep/account/guest-sign-in?padId='+encodeURIComponent(localPadId)); + } + } +} + +function _checkPasswordSecurity(globalPadId) { + if (!getSession().padPasswordAuth) { + getSession().padPasswordAuth = {}; + } + if (getSession().padPasswordAuth[globalPadId] == true) { + return; + } + var domainId = padutils.getDomainId(globalPadId); + var localPadId = globalPadId.split("$")[1]; + + if (stringutils.startsWith(request.path, "/ep/admin/recover-padtext")) { + return; + } + + var p = pro_padmeta.accessProPad(globalPadId, function(propad) { + if (propad.exists()) { + return propad.getPassword(); + } else { + return null; + } + }); + if (p) { + response.redirect('/ep/pad/auth/'+localPadId+'?cont='+encodeURIComponent(request.url)); + } +} + diff --git a/trunk/etherpad/src/etherpad/pad/padevents.js b/trunk/etherpad/src/etherpad/pad/padevents.js new file mode 100644 index 0000000..52b303c --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/padevents.js @@ -0,0 +1,170 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// src/etherpad/events.js + +import("etherpad.licensing"); +import("etherpad.log"); +import("etherpad.pad.chatarchive"); +import("etherpad.pad.activepads"); +import("etherpad.pad.padutils"); +import("etherpad.sessions"); +import("etherpad.pro.pro_padmeta"); +import("etherpad.pro.pro_pad_db"); +import("etherpad.pad.padusers"); +import("etherpad.pad.pad_security"); +import("etherpad.pad.noprowatcher"); +import("etherpad.collab.collab_server"); +jimport("java.lang.System.out.println"); + +function onNewPad(pad) { + log.custom("padevents", { + type: "newpad", + padId: pad.getId() + }); + pro_pad_db.onCreatePad(pad); +} + +function onDestroyPad(pad) { + log.custom("padevents", { + type: "destroypad", + padId: pad.getId() + }); + pro_pad_db.onDestroyPad(pad); +} + +function onUserJoin(pad, userInfo) { + log.callCatchingExceptions(function() { + + var name = userInfo.name || "unnamed"; + log.custom("padevents", { + type: "userjoin", + padId: pad.getId(), + username: name, + ip: userInfo.ip, + userId: userInfo.userId + }); + activepads.touch(pad.getId()); + licensing.onUserJoin(userInfo); + log.onUserJoin(userInfo.userId); + padusers.notifyActive(); + noprowatcher.onUserJoin(pad, userInfo); + + }); +} + +function onUserLeave(pad, userInfo) { + log.callCatchingExceptions(function() { + + var name = userInfo.name || "unnamed"; + log.custom("padevents", { + type: "userleave", + padId: pad.getId(), + username: name, + ip: userInfo.ip, + userId: userInfo.userId + }); + activepads.touch(pad.getId()); + licensing.onUserLeave(userInfo); + noprowatcher.onUserLeave(pad, userInfo); + + }); +} + +function onUserInfoChange(pad, userInfo) { + log.callCatchingExceptions(function() { + + activepads.touch(pad.getId()); + + }); +} + +function onClientMessage(pad, senderUserInfo, msg) { + var padId = pad.getId(); + activepads.touch(padId); + + if (msg.type == "chat") { + + chatarchive.onChatMessage(pad, senderUserInfo, msg); + + var name = "unnamed"; + if (senderUserInfo.name) { + name = senderUserInfo.name; + } + + log.custom("chat", { + padId: padId, + userId: senderUserInfo.userId, + username: name, + text: msg.lineText + }); + } + else if (msg.type == "padtitle") { + if (msg.title && padutils.isProPadId(pad.getId())) { + pro_padmeta.accessProPad(pad.getId(), function(propad) { + propad.setTitle(String(msg.title).substring(0, 80)); + }); + } + } + else if (msg.type == "padpassword") { + if (padutils.isProPadId(pad.getId())) { + pro_padmeta.accessProPad(pad.getId(), function(propad) { + propad.setPassword(msg.password || null); + }); + } + } + else if (msg.type == "padoptions") { + // options object is a full set of options or just + // some options to change + var opts = msg.options; + var padOptions = pad.getPadOptionsObj(); + if (opts.view) { + if (! padOptions.view) { + padOptions.view = {}; + } + for(var k in opts.view) { + padOptions.view[k] = opts.view[k]; + } + } + if (opts.guestPolicy) { + padOptions.guestPolicy = opts.guestPolicy; + if (opts.guestPolicy == 'deny') { + // boot guests! + collab_server.bootUsersFromPad(pad, "unauth", function(userInfo) { + return padusers.isGuest(userInfo.userId); }).forEach(function(userInfo) { + pad_security.revokePadUserAccess(padId, userInfo.userId); }); + } + } + } + else if (msg.type == "guestanswer") { + if ((! msg.authId) || padusers.isGuest(msg.authId)) { + // not a pro user, forbid. + } + else { + pad_security.answerKnock(msg.guestId, padId, msg.answer); + } + } +} + +function onEditPad(pad, authorId) { + log.callCatchingExceptions(function() { + + pro_pad_db.onEditPad(pad, authorId); + + }); +} + + diff --git a/trunk/etherpad/src/etherpad/pad/padusers.js b/trunk/etherpad/src/etherpad/pad/padusers.js new file mode 100644 index 0000000..f04f0eb --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/padusers.js @@ -0,0 +1,397 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("fastJSON"); +import("stringutils"); +import("jsutils.eachProperty"); +import("sync"); +import("etherpad.sessions"); +import("etherpad.pro.pro_utils"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_accounts.getSessionProAccount"); +import("etherpad.pro.domains"); +import("stringutils.randomHash"); + +var _table = cachedSqlTable('pad_guests', 'pad_guests', + ['id', 'privateKey', 'userId'], processGuestRow); +function processGuestRow(row) { + row.data = fastJSON.parse(row.data); +} + +function notifySignIn() { + /*if (pro_accounts.isAccountSignedIn()) { + var proId = getUserId(); + var guestId = _getGuestUserId(); + + var guestUser = _getGuestByKey('userId', guestId); + if (guestUser) { + var mods = {}; + mods.data = guestUser.data; + // associate guest with proId + mods.data.replacement = proId; + // de-associate ET cookie with guest, otherwise + // the ET cookie would provide a semi-permanent way + // to effect changes under the pro account's name! + mods.privateKey = "replaced$"+_randomString(20); + _updateGuest('userId', guestId, mods); + } + }*/ +} + +function notifyActive() { + if (isGuest(getUserId())) { + _updateGuest('userId', getUserId(), {}); + } +} + +function notifyUserData(userData) { + var uid = getUserId(); + if (isGuest(uid)) { + var data = _getGuestByKey('userId', uid).data; + if (userData.name) { + data.name = userData.name; + } + _updateGuest('userId', uid, {data: data}); + } +} + +function getUserId() { + if (pro_accounts.isAccountSignedIn()) { + return "p."+(getSessionProAccount().id); + } + else { + return getGuestUserId(); + } +} + +function getUserName() { + var uid = getUserId(); + if (isGuest(uid)) { + var fromSession = sessions.getSession().guestDisplayName; + return fromSession || _getGuestByKey('userId', uid).data.name || null; + } + else { + return getSessionProAccount().fullName; + } +} + +function getAccountIdForProAuthor(uid) { + if (uid.indexOf("p.") == 0) { + return Number(uid.substring(2)); + } + else { + return -1; + } +} + +function getNameForUserId(uid) { + if (isGuest(uid)) { + return _getGuestByKey('userId', uid).data.name || null; + } + else { + var accountNum = getAccountIdForProAuthor(uid); + if (accountNum < 0) { + return null; + } + else { + return pro_accounts.getAccountById(accountNum).fullName; + } + } +} + +function isGuest(userId) { + return /^g/.test(userId); +} + +function getGuestUserId() { + // cache the userId in the requestCache, + // for efficiency and consistency + var c = appjet.requestCache; + if (c.padGuestUserId === undefined) { + c.padGuestUserId = _computeGuestUserId(); + } + return c.padGuestUserId; +} + +function _getGuestTrackerId() { + // get ET cookie + var tid = sessions.getTrackingId(); + if (tid == '-') { + // no tracking cookie? not a normal request? + return null; + } + + // get domain ID + var domain = "-"; + if (pro_utils.isProDomainRequest()) { + // e.g. "3" + domain = String(domains.getRequestDomainId()); + } + + // combine them + return domain+"$"+tid; +} + +function _insertGuest(obj) { + // only requires 'userId' in obj + + obj.createdDate = new Date; + obj.lastActiveDate = new Date; + if (! obj.data) { + obj.data = {}; + } + if ((typeof obj.data) == "object") { + obj.data = fastJSON.stringify(obj.data); + } + if (! obj.privateKey) { + // private keys must be unique + obj.privateKey = "notracker$"+_randomString(20); + } + + return _table.insert(obj); +} + +function _getGuestByKey(keyColumn, value) { + return _table.getByKey(keyColumn, value); +} + +function _updateGuest(keyColumn, value, obj) { + var obj2 = {}; + eachProperty(obj, function(k,v) { + if (k == "data" && (typeof v) == "object") { + obj2.data = fastJSON.stringify(v); + } + else { + obj2[k] = v; + } + }); + + obj2.lastActiveDate = new Date; + + _table.updateByKey(keyColumn, value, obj2); +} + +function _newGuestUserId() { + return "g."+_randomString(16); +} + +function _computeGuestUserId() { + // always returns some userId + + var privateKey = _getGuestTrackerId(); + + if (! privateKey) { + // no tracking cookie, pretend there is one + privateKey = randomHash(16); + } + + var userFromTracker = _table.getByKey('privateKey', privateKey); + if (userFromTracker) { + // we know this guy + return userFromTracker.userId; + } + + // generate userId + var userId = _newGuestUserId(); + var guest = {userId:userId, privateKey:privateKey}; + var data = {}; + guest.data = data; + + var prefsCookieData = _getPrefsCookieData(); + if (prefsCookieData) { + // found an old prefs cookie with an old userId + var oldUserId = prefsCookieData.userId; + // take the name and preferences + if ('name' in prefsCookieData) { + data.name = prefsCookieData.name; + } + /*['fullWidth','viewZoom'].forEach(function(pref) { + if (pref in prefsCookieData) { + data.prefs[pref] = prefsCookieData[pref]; + } + });*/ + } + + _insertGuest(guest); + return userId; +} + +function _getPrefsCookieData() { + // get userId from old prefs cookie if possible, + // but don't allow modern usernames + + var prefsCookie = request.cookies['prefs']; + if (! prefsCookie) { + return null; + } + if (prefsCookie.charAt(0) != '%') { + return null; + } + try { + var cookieData = fastJSON.parse(unescape(prefsCookie)); + // require one to three digits followed by dot at beginning of userId + if (/^[0-9]{1,3}\./.test(String(cookieData.userId))) { + return cookieData; + } + } + catch (e) { + return null; + } + + return null; +} + +function _randomString(len) { + // use only numbers and lowercase letters + var pieces = []; + for(var i=0;i<len;i++) { + pieces.push(Math.floor(Math.random()*36).toString(36).slice(-1)); + } + return pieces.join(''); +} + + +function cachedSqlTable(cacheName, tableName, keyColumns, processFetched) { + // Keeps a cache of sqlobj rows for the case where + // you want to select one row at a time by a single column + // at a time, taken from some set of key columns. + // The cache maps (keyColumn, value), e.g. ("id", 4) or + // ("secondaryKey", "foo123"), to an object, and each + // object is either present for all keyColumns + // (e.g. "id", "secondaryKey") or none. + + if ((typeof keyColumns) == "string") { + keyColumns = [keyColumns]; + } + processFetched = processFetched || (function(o) {}); + + function getCache() { + // this function is normally fast, only slow when cache + // needs to be created for the first time + var cache = appjet.cache[cacheName]; + if (cache) { + return cache; + } + else { + // initialize in a synchronized block (double-checked locking); + // uses same lock as cache_utils.syncedWithCache would use. + sync.doWithStringLock("cache/"+cacheName, function() { + if (! appjet.cache[cacheName]) { + // values expire after 10 minutes + appjet.cache[cacheName] = + new net.appjet.common.util.ExpiringMapping(10*60*1000); + } + }); + return appjet.cache[cacheName]; + } + } + + function cacheKey(keyColumn, value) { + // e.g. "id$4" + return keyColumn+"$"+String(value); + } + + function getFromCache(keyColumn, value) { + return getCache().get(cacheKey(keyColumn, value)); + } + function putInCache(obj) { + var cache = getCache(); + // put in cache, keyed on all keyColumns we care about + keyColumns.forEach(function(keyColumn) { + cache.put(cacheKey(keyColumn, obj[keyColumn]), obj); + }); + } + function touchInCache(obj) { + var cache = getCache(); + keyColumns.forEach(function(keyColumn) { + cache.touch(cacheKey(keyColumn, obj[keyColumn])); + }); + } + function removeObjFromCache(obj) { + var cache = getCache(); + keyColumns.forEach(function(keyColumn) { + cache.remove(cacheKey(keyColumn, obj[keyColumn])); + }); + } + function removeFromCache(keyColumn, value) { + var cached = getFromCache(keyColumn, value); + if (cached) { + removeObjFromCache(cached); + } + } + + var self = { + clearCache: function() { + getCache().clear(); + }, + getByKey: function(keyColumn, value) { + // get cached object, if any + var cached = getFromCache(keyColumn, value); + if (! cached) { + // nothing in cache for this query, fetch from SQL + var keyToValue = {}; + keyToValue[keyColumn] = value; + var fetched = sqlobj.selectSingle(tableName, keyToValue); + if (fetched) { + processFetched(fetched); + // fetched something, stick it in the cache + putInCache(fetched); + } + return fetched; + } + else { + // touch cached object and return + touchInCache(cached); + return cached; + } + }, + updateByKey: function(keyColumn, value, obj) { + var keyToValue = {}; + keyToValue[keyColumn] = value; + sqlobj.updateSingle(tableName, keyToValue, obj); + // remove old object from caches but + // don't put obj in cache, because it + // is likely a partial object + removeFromCache(keyColumn, value); + }, + insert: function(obj) { + var returnVal = sqlobj.insert(tableName, obj); + // remove old object from caches but + // don't put obj in the cache; it doesn't + // have all values, e.g. for auto-generated ids + removeObjFromCache(obj); + return returnVal; + }, + deleteByKey: function(keyColumn, value) { + var keyToValue = {}; + keyToValue[keyColumn] = value; + sqlobj.deleteRows(tableName, keyToValue); + removeFromCache(keyColumn, value); + } + }; + return self; +} + +function _getClientIp() { + return (request.isDefined && request.clientIp) || ''; +} + +function getUserIdCreatedDate(userId) { + var record = sqlobj.selectSingle('pad_cookie_userids', {id: userId}); + if (! record) { return; } // hm. weird case. + return record.createdDate; +} diff --git a/trunk/etherpad/src/etherpad/pad/padutils.js b/trunk/etherpad/src/etherpad/pad/padutils.js new file mode 100644 index 0000000..3ffe70c --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/padutils.js @@ -0,0 +1,154 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("fastJSON"); +import("stringutils"); + +import("etherpad.control.pro.account_control"); + +import("etherpad.pro.pro_utils"); +import("etherpad.pro.domains"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_padmeta"); +import("etherpad.pad.model"); +import("etherpad.sessions.getSession"); + +jimport("java.lang.System.out.println"); + + +function setCurrentPad(p) { + appjet.context.attributes().update("currentPadId", p); +} + +function clearCurrentPad() { + appjet.context.attributes()['$minus$eq']("currentPadId"); +} + +function getCurrentPad() { + var padOpt = appjet.context.attributes().get("currentPadId"); + if (padOpt.isEmpty()) return null; + return padOpt.get(); +} + +function _parseCookie(text) { + try { + var cookieData = fastJSON.parse(unescape(text)); + return cookieData; + } + catch (e) { + return null; + } +} + +function getPrefsCookieData() { + var prefsCookie = request.cookies['prefs']; + if (!prefsCookie) { + return null; + } + + return _parseCookie(prefsCookie); +} + +function getPrefsCookieUserId() { + var cookieData = getPrefsCookieData(); + if (! cookieData) { + return null; + } + return cookieData.userId || null; +} + +/** + * Not valid to call this function outisde a HTTP request. + */ +function accessPadLocal(localPadId, fn, rwMode) { + if (!request.isDefined) { + throw Error("accessPadLocal() cannot run outside an HTTP request."); + } + var globalPadId = getGlobalPadId(localPadId); + var fnwrap = function(pad) { + pad.getLocalId = function() { + return getLocalPadId(pad); + }; + return fn(pad); + } + return model.accessPadGlobal(globalPadId, fnwrap, rwMode); +} + +/** + * Not valid to call this function outisde a HTTP request. + */ +function getGlobalPadId(localPadId) { + if (!request.isDefined) { + throw Error("getGlobalPadId() cannot run outside an HTTP request."); + } + if (pro_utils.isProDomainRequest()) { + return makeGlobalId(domains.getRequestDomainId(), localPadId); + } else { + // pad.spline.inf.fu-berlin.de pads + return localPadId; + } +} + +function makeGlobalId(domainId, localPadId) { + return [domainId, localPadId].map(String).join('$'); +} + +function globalToLocalId(globalId) { + var parts = globalId.split('$'); + if (parts.length == 1) { + return parts[0]; + } else { + return parts[1]; + } +} + +function getLocalPadId(pad) { + var globalId = pad.getId(); + return globalToLocalId(globalId); +} + +function isProPadId(globalPadId) { + return (globalPadId.indexOf("$") > 0); +} + +function isProPad(pad) { + return isProPadId(pad.getId()); +} + +function getDomainId(globalPadId) { + var parts = globalPadId.split("$"); + if (parts.length < 2) { + return null; + } else { + return Number(parts[0]); + } +} + +function makeValidLocalPadId(str) { + return str.replace(/[^a-zA-Z0-9\-]/g, '-'); +} + +function getProDisplayTitle(localPadId, title) { + if (title) { + return title; + } + if (stringutils.isNumeric(localPadId)) { + return ("Untitled "+localPadId); + } else { + return (localPadId); + } +} + diff --git a/trunk/etherpad/src/etherpad/pad/revisions.js b/trunk/etherpad/src/etherpad/pad/revisions.js new file mode 100644 index 0000000..c7c84e8 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/revisions.js @@ -0,0 +1,103 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("jsutils.cmp"); +import("stringutils"); + +import("etherpad.utils.*"); + +jimport("java.lang.System.out.println"); + +/* revisionList is an array of revisionInfo structures. + * + * Each revisionInfo structure looks like: + * + * { + * timestamp: a number unix timestamp + * label: string + * savedBy: string of author name + * savedById: string id of the author + * revNum: revision number in the edit history + * id: the view id of the (formerly the id of the StorableObject) + * } + */ + +/* returns array */ +function _getRevisionsArray(pad) { + var dataRoot = pad.getDataRoot(); + if (!dataRoot.savedRevisions) { + dataRoot.savedRevisions = []; + } + dataRoot.savedRevisions.sort(function(a,b) { + return cmp(b.timestamp, a.timestamp); + }); + return dataRoot.savedRevisions; +} + +function _getPadRevisionById(pad, savedRevId) { + var revs = _getRevisionsArray(pad); + var rev; + for(var i=0;i<revs.length;i++) { + if (revs[i].id == savedRevId) { + rev = revs[i]; + break; + } + } + return rev || null; +} + +/*----------------------------------------------------------------*/ +/* public functions */ +/*----------------------------------------------------------------*/ + +function getRevisionList(pad) { + return _getRevisionsArray(pad); +} + +function saveNewRevision(pad, savedBy, savedById, revisionNumber, optIP, optTimestamp, optId) { + var revArray = _getRevisionsArray(pad); + var rev = { + timestamp: (optTimestamp || (+(new Date))), + label: null, + savedBy: savedBy, + savedById: savedById, + revNum: revisionNumber, + ip: (optIP || request.clientAddr), + id: (optId || stringutils.randomString(10)) // *probably* unique + }; + revArray.push(rev); + rev.label = "Revision "+revArray.length; + return rev; +} + +function setLabel(pad, savedRevId, userId, newLabel) { + var rev = _getPadRevisionById(pad, savedRevId); + if (!rev) { + throw new Error("revision does not exist: "+savedRevId); + } + /*if (rev.savedById != userId) { + throw new Error("cannot label someone else's revision."); + } + if (((+new Date) - rev.timestamp) > (24*60*60*1000)) { + throw new Error("revision is too old to label: "+savedRevId); + }*/ + rev.label = newLabel; +} + +function getStoredRevision(pad, savedRevId) { + return _getPadRevisionById(pad, savedRevId); +} + diff --git a/trunk/etherpad/src/etherpad/pne/pne_utils.js b/trunk/etherpad/src/etherpad/pne/pne_utils.js new file mode 100644 index 0000000..74e0598 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pne/pne_utils.js @@ -0,0 +1,187 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("funhtml.*"); +import("stringutils.md5"); +import("sqlbase.persistent_vars"); + +import("etherpad.licensing"); + +jimport("java.lang.System.out.println"); +jimport("java.lang.System"); + + +function isPNE() { + if (appjet.cache.fakePNE || appjet.config['etherpad.fakePNE']) { + return true; + } + if (getVersionString()) { + return true; + } + return false; +} + +/** + * Versioning scheme: we basically just use the apache scheme of MAJOR.MINOR.PATCH: + * + * Versions are denoted using a standard triplet of integers: MAJOR.MINOR.PATCH. The + * basic intent is that MAJOR versions are incompatible, large-scale upgrades of the API. + * MINOR versions retain source and binary compatibility with older minor versions, and + * changes in the PATCH level are perfectly compatible, forwards and backwards. + */ + +function getVersionString() { + return appjet.config['etherpad.pneVersion']; +} + +function parseVersionString(x) { + var parts = x.split('.'); + return { + major: Number(parts[0] || 0), + minor: Number(parts[1] || 0), + patch: Number(parts[2] || 0) + }; +} + +/* returns {major: int, minor: int, patch: int} */ +function getVersionNumbers() { + return parseVersionString(getVersionString()); +} + +function checkDbVersionUpgrade() { + var dbVersionString = persistent_vars.get("db_pne_version"); + var runningVersionString = getVersionString(); + + if (!dbVersionString) { + println("Upgrading to Private Network Edition, version: "+runningVersionString); + return; + } + + var dbVersion = parseVersionString(dbVersionString); + var runningVersion = getVersionNumbers(); + var force = (appjet.config['etherpad.forceDbUpgrade'] == "true"); + + if (!force && (runningVersion.major != dbVersion.major)) { + println("Error: you are attempting to update an EtherPad["+dbVersionString+ + "] database to version ["+runningVersionString+"]. This is not possible."); + println("Exiting..."); + System.exit(1); + } + if (!force && (runningVersion.minor < dbVersion.minor)) { + println("Error: your etherpad database is at a newer version ["+dbVersionString+"] than"+ + " the current running etherpad ["+runningVersionString+"]. Please upgrade to the "+ + " latest version."); + println("Exiting..."); + System.exit(1); + } + if (!force && (runningVersion.minor > (dbVersion.minor + 1))) { + println("\n\nWARNING: you are attempting to upgrade from version "+dbVersionString+" to version "+ + runningVersionString+". It is recommended that you upgrade one minor version at a time."+ + " (The \"minor\" version number is the second number separated by dots. For example,"+ + " if you are running version 1.2, it is recommended that you upgrade to 1.3 and then 1.4 "+ + " instead of going directly from 1.2 to 1.4."); + println("\n\nIf you really want to do this, you can force us to attempt the upgrade with "+ + " the --etherpad.forceDbUpgrade=true flag."); + println("\n\nExiting..."); + System.exit(1); + } + if (runningVersion.minor > dbVersion.minor) { + println("Upgrading database to version "+runningVersionString); + } +} + +function saveDbVersion() { + var dbVersionString = persistent_vars.get("db_pne_version"); + if (getVersionString() != dbVersionString) { + persistent_vars.put('db_pne_version', getVersionString()); + println("Upgraded Private Network Edition version to ["+getVersionString()+"]"); + } +} + +// These are a list of some of the config vars documented in the PNE manual. They are here +// temporarily, until we move them to the PNE config UI. + +var _eepneAllowedConfigVars = [ + 'configFile', + 'etherpad.useMySQL', + 'etherpad.SQL_JDBC_DRIVER', + 'etherpad.SQL_JDBC_URL', + 'etherpad.SQL_PASSWORD', + 'etherpad.SQL_USERNAME', + 'etherpad.adminPass', + 'etherpad.licenseKey', + 'listen', + 'listenSecure', + 'smtpPass', + 'smtpServer', + 'smtpUser', + 'sslKeyPassword', + 'sslKeyStore' +]; + +function isServerLicensed() { + var licenseInfo = licensing.getLicense(); + if (!licenseInfo) { + return false; + } + if (licensing.isVersionTooOld()) { + return false; + } + if (licensing.isExpired()) { + return false; + } + return true; +} + +function enableTrackingAgain() { + delete appjet.cache.noMorePneTracking; +} + +function pneTrackerHtml() { + if (!isPNE()) { + return ""; + } + if (appjet.cache.noMorePneTracking) { + return ""; + } + + var div = DIV({style: "height: 1px; width: 1px; overflow: hidden;"}); + + var licenseInfo = licensing.getLicense(); + var key = null; + if (licenseInfo) { + key = md5(licenseInfo.key).substr(0, 16); + } + + function trackData(name, value) { + var imgurl = "http://pad.spline.inf.fu-berlin.de/ep/tpne/t?"; + if (key) { + imgurl += ("k="+key+"&"); + } + imgurl += (encodeURIComponent(name) + "=" + encodeURIComponent(value)); + div.push(IMG({src: imgurl})); + } + + trackData("ping", "1"); + trackData("dbdriver", appjet.config['etherpad.SQL_JDBC_DRIVER']); + trackData("request.url", request.url); + + appjet.cache.noMorePneTracking = true; + return div; +} + + + diff --git a/trunk/etherpad/src/etherpad/pro/domains.js b/trunk/etherpad/src/etherpad/pro/domains.js new file mode 100644 index 0000000..e56a408 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pro/domains.js @@ -0,0 +1,141 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Library for managing subDomains + +import("jsutils.*"); +import("sqlbase.sqlobj"); + +import("etherpad.pro.pro_utils"); +import("etherpad.pne.pne_utils"); +import("etherpad.licensing"); + +jimport("java.lang.System.out.println"); + +// reserved domains +var reservedSubdomains = { + 'alpha': 1, + 'beta': 1, + 'blog': 1, + 'comet': 1, + 'diagnostic': 1, + 'forums': 1, + 'forumsdev': 1, + 'staging': 1, + 'web': 1, + 'www': 1 +}; + +function _getCache() { + if (!appjet.cache.pro_domains) { + appjet.cache.pro_domains = { + records: {id: {}, subDomain: {}} + }; + } + return appjet.cache.pro_domains; +} + +function doesSubdomainExist(subDomain) { + if (reservedSubdomains[subDomain]) { + return true; + } + if (getDomainRecordFromSubdomain(subDomain) != null) { + return true; + } + return false; +} + +function _updateCache(locator) { + var record = sqlobj.selectSingle('pro_domains', locator); + var recordCache = _getCache().records; + + if (record) { + // update both maps: recordCache.id, recordCache.subDomain + keys(recordCache).forEach(function(key) { + recordCache[key][record[key]] = record; + }); + } else { + // write false for whatever hit with this locator + keys(locator).forEach(function(key) { + recordCache[key][locator[key]] = false; + }); + } +} + +function getDomainRecord(domainId) { + if (!(domainId in _getCache().records.id)) { + _updateCache({id: domainId}); + } + var record = _getCache().records.id[domainId]; + return (record ? record : null); +} + +function getDomainRecordFromSubdomain(subDomain) { + subDomain = subDomain.toLowerCase(); + if (!(subDomain in _getCache().records.subDomain)) { + _updateCache({subDomain: subDomain}); + } + var record = _getCache().records.subDomain[subDomain]; + return (record ? record : null); +} + +/** returns id of newly created subDomain */ +function createNewSubdomain(subDomain, orgName) { + var id = sqlobj.insert('pro_domains', {subDomain: subDomain, orgName: orgName}); + _updateCache({id: id}); + return id; +} + +function getPrivateNetworkDomainId() { + var r = getDomainRecordFromSubdomain('<<private-network>>'); + if (!r) { + throw Error("<<private-network>> does not exist in the domains table!"); + } + return r.id; +} + +/** returns null if not found. */ +function getRequestDomainRecord() { + if (pne_utils.isPNE()) { + var r = getDomainRecord(getPrivateNetworkDomainId()); + if (appjet.cache.fakePNE) { + r.orgName = "fake"; + } else { + var licenseInfo = licensing.getLicense(); + if (licenseInfo) { + r.orgName = licenseInfo.organizationName; + } else { + r.orgName = "Private Network Edition TRIAL"; + } + } + return r; + } else { + var subDomain = pro_utils.getProRequestSubdomain(); + var r = getDomainRecordFromSubdomain(subDomain); + return r; + } +} + +/* throws exception if not pro domain request. */ +function getRequestDomainId() { + var r = getRequestDomainRecord(); + if (!r) { + throw Error("Error getting request domain id."); + } + return r.id; +} + + diff --git a/trunk/etherpad/src/etherpad/pro/pro_account_auto_signin.js b/trunk/etherpad/src/etherpad/pro/pro_account_auto_signin.js new file mode 100644 index 0000000..ebcd227 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pro/pro_account_auto_signin.js @@ -0,0 +1,101 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlobj"); +import("stringutils"); + +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_accounts.getSessionProAccount"); + +jimport("java.lang.System.out.println"); + +var _COOKIE_NAME = "PUAS"; + +function dmesg(m) { + if (false) { + println("[pro-account-auto-sign-in]: "+m); + } +} + +function checkAutoSignin() { + dmesg("checking auto sign-in..."); + if (pro_accounts.isAccountSignedIn()) { + dmesg("account already signed in..."); + // don't mess with already signed-in account + return; + } + var cookie = request.cookies[_COOKIE_NAME]; + if (!cookie) { + dmesg("no auto-sign-in cookie found..."); + return; + } + var record = sqlobj.selectSingle('pro_accounts_auto_signin', {cookie: cookie}, {}); + if (!record) { + return; + } + + var now = +(new Date); + if (+record.expires < now) { + sqlobj.deleteRows('pro_accounts_auto_signin', {id: record.id}); + response.deleteCookie(_COOKIE_NAME); + dmesg("deleted expired record..."); + return; + } + // do auto-signin (bypasses normal security) + dmesg("Doing auto sign in..."); + var account = pro_accounts.getAccountById(record.accountId); + pro_accounts.signInSession(account); + response.redirect('/ep/account/sign-in?cont='+encodeURIComponent(request.url)); +} + +function setAutoSigninCookie(rememberMe) { + if (!pro_accounts.isAccountSignedIn()) { + return; // only call this function after account is already signed in. + } + + var accountId = getSessionProAccount().id; + // delete any existing auto-signins for this account. + sqlobj.deleteRows('pro_accounts_auto_signin', {accountId: accountId}); + + // set this insecure cookie just to indicate that account is auto-sign-in-able + response.setCookie({ + name: "ASIE", + value: (rememberMe ? "T" : "F"), + path: "/", + domain: request.domain, + expires: new Date(32503708800000), // year 3000 + }); + + if (!rememberMe) { + return; + } + + var cookie = stringutils.randomHash(16); + var now = +(new Date); + var expires = new Date(now + 1000*60*60*24*30); // 30 days + //var expires = new Date(now + 1000 * 60 * 5); // 2 minutes + + sqlobj.insert('pro_accounts_auto_signin', {cookie: cookie, accountId: accountId, expires: expires}); + response.setCookie({ + name: _COOKIE_NAME, + value: cookie, + path: "/ep/account/", + domain: request.domain, + expires: new Date(32503708800000), // year 3000 + secure: true + }); +} + diff --git a/trunk/etherpad/src/etherpad/pro/pro_accounts.js b/trunk/etherpad/src/etherpad/pro/pro_accounts.js new file mode 100644 index 0000000..2024970 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pro/pro_accounts.js @@ -0,0 +1,496 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// library for pro accounts + +import("funhtml.*"); +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon.inTransaction"); +import("email.sendEmail"); +import("cache_utils.syncedWithCache"); +import("stringutils.*"); + +import("etherpad.globals.*"); +import("etherpad.sessions"); +import("etherpad.sessions.getSession"); +import("etherpad.utils.*"); +import("etherpad.pro.domains"); +import("etherpad.control.pro.account_control"); +import("etherpad.pro.pro_utils"); +import("etherpad.pro.pro_quotas"); +import("etherpad.pad.padusers"); +import("etherpad.log"); +import("etherpad.billing.team_billing"); + +jimport("org.mindrot.BCrypt"); +jimport("java.lang.System.out.println"); + +function _dmesg(m) { + if (!isProduction()) { + println(m); + } +} + +function _computePasswordHash(p) { + var pwh; + pwh = BCrypt.hashpw(p, BCrypt.gensalt(10)); + return pwh; +} + +function _withCache(name, fn) { + return syncedWithCache('pro_accounts.'+name, fn); +} + +//---------------------------------------------------------------- +// validation +//---------------------------------------------------------------- + +function validateEmail(email) { + if (!email) { return "Email is required."; } + if (!isValidEmail(email)) { return "\""+email+"\" does not look like a valid email address."; } + return null; +} + +function validateFullName(name) { + if (!name) { return "Full name is required."; } + if (name.length < 2) { return "Full name must be at least 2 characters."; } + return null; +} + +function validatePassword(p) { + if (!p) { return "Password is required."; } + if (p.length < 6) { return "Passwords must be at least 6 characters."; } + return null; +} + +function validateEmailDomainPair(email, domainId) { + // TODO: make sure the same email address cannot exist more than once within + // the same domainid. +} + +/* if domainId is null, then use domainId of current request. */ +function createNewAccount(domainId, fullName, email, password, isAdmin) { + if (!domainId) { + domainId = domains.getRequestDomainId(); + } + email = trim(email); + isAdmin = !!isAdmin; // convert to bool + + // validation + var e; + e = validateEmail(email); if (e) { throw Error(e); } + e = validateFullName(fullName); if (e) { throw Error(e); } + e = validatePassword(password); if (e) { throw Error(e); } + + // xss normalization + fullName = toHTML(fullName); + + // make sure account does not already exist on this domain. + var ret = inTransaction(function() { + var existingAccount = getAccountByEmail(email, domainId); + if (existingAccount) { + throw Error("There is already an account with that email address."); + } + // No existing account. Proceed. + var now = new Date(); + var account = { + domainId: domainId, + fullName: fullName, + email: email, + passwordHash: _computePasswordHash(password), + createdDate: now, + isAdmin: isAdmin + }; + return sqlobj.insert('pro_accounts', account); + }); + + _withCache('does-domain-admin-exist', function(cache) { + delete cache[domainId]; + }); + + pro_quotas.updateAccountUsageCount(domainId); + updateCachedActiveCount(domainId); + + if (ret) { + log.custom('pro-accounts', + {type: "account-created", + accountId: ret, + domainId: domainId, + name: fullName, + email: email, + admin: isAdmin}); + } + + return ret; +} + +function _checkAccess(account) { + if (sessions.isAnEtherpadAdmin()) { + return; + } + if (account.domainId != domains.getRequestDomainId()) { + throw Error("access denied"); + } +} + +function setPassword(account, newPass) { + _checkAccess(account); + var passHash = _computePasswordHash(newPass); + sqlobj.update('pro_accounts', {id: account.id}, {passwordHash: passHash}); + markDirtySessionAccount(account.id); +} + +function setTempPassword(account, tempPass) { + _checkAccess(account); + var tempPassHash = _computePasswordHash(tempPass); + sqlobj.update('pro_accounts', {id: account.id}, {tempPassHash: tempPassHash}); + markDirtySessionAccount(account.id); +} + +function setEmail(account, newEmail) { + _checkAccess(account); + sqlobj.update('pro_accounts', {id: account.id}, {email: newEmail}); + markDirtySessionAccount(account.id); +} + +function setFullName(account, newName) { + _checkAccess(account); + sqlobj.update('pro_accounts', {id: account.id}, {fullName: newName}); + markDirtySessionAccount(account.id); +} + +function setIsAdmin(account, newVal) { + _checkAccess(account); + sqlobj.update('pro_accounts', {id: account.id}, {isAdmin: newVal}); + markDirtySessionAccount(account.id); +} + +function setDeleted(account) { + _checkAccess(account); + if (!isNumeric(account.id)) { + throw new Error("Invalid account id: "+account.id); + } + sqlobj.update('pro_accounts', {id: account.id}, {isDeleted: true}); + markDirtySessionAccount(account.id); + pro_quotas.updateAccountUsageCount(account.domainId); + updateCachedActiveCount(account.domainId); + + log.custom('pro-accounts', + {type: "account-deleted", + accountId: account.id, + domainId: account.domainId, + name: account.fullName, + email: account.email, + admin: account.isAdmin, + createdDate: account.createdDate.getTime()}); +} + +//---------------------------------------------------------------- + +function doesAdminExist() { + var domainId = domains.getRequestDomainId(); + return _withCache('does-domain-admin-exist', function(cache) { + if (cache[domainId] === undefined) { + _dmesg("cache miss for doesAdminExist (domainId="+domainId+")"); + var admins = sqlobj.selectMulti('pro_accounts', {domainId: domainId, isAdmin: true}, {}); + cache[domainId] = (admins.length > 0); + } + return cache[domainId] + }); +} + +function getSessionProAccount() { + if (sessions.isAnEtherpadAdmin()) { + return getEtherpadAdminAccount(); + } + var account = getSession().proAccount; + if (!account) { + return null; + } + if (account.isDeleted) { + delete getSession().proAccount; + return null; + } + return account; +} + +function isAccountSignedIn() { + if (getSessionProAccount()) { + return true; + } else { + return false; + } +} + +function isAdminSignedIn() { + return isAccountSignedIn() && getSessionProAccount().isAdmin; +} + +function requireAccount(message) { + if ((request.path == "/ep/account/sign-in") || + (request.path == "/ep/account/sign-out") || + (request.path == "/ep/account/guest-sign-in") || + (request.path == "/ep/account/guest-knock") || + (request.path == "/ep/account/forgot-password")) { + return; + } + + function checkSessionAccount() { + if (!getSessionProAccount()) { + if (message) { + account_control.setSigninNotice(message); + } + response.redirect('/ep/account/sign-in?cont='+encodeURIComponent(request.url)); + } + } + + checkSessionAccount(); + + if (getSessionProAccount().domainId != domains.getRequestDomainId()) { + // This should theoretically never happen unless the account is spoofing cookies / trying to + // hack the site. + pro_utils.renderFramedMessage("Permission denied."); + response.stop(); + } + // update dirty session account if necessary + _withCache('dirty-session-accounts', function(cache) { + var uid = getSessionProAccount().id; + if (cache[uid]) { + reloadSessionAccountData(uid); + cache[uid] = false; + } + }); + + // need to check again in case dirty update caused account to be marked + // deleted. + checkSessionAccount(); +} + +function requireAdminAccount() { + requireAccount(); + if (!getSessionProAccount().isAdmin) { + pro_utils.renderFramedMessage("Permission denied."); + response.stop(); + } +} + +/* returns undefined on success, error string otherise. */ +function authenticateSignIn(email, password) { + var accountRecord = getAccountByEmail(email, null); + if (!accountRecord) { + return "Account not found: "+email; + } + + if (BCrypt.checkpw(password, accountRecord.passwordHash) != true) { + return "Incorrect password. Please try again."; + } + + signInSession(accountRecord); + + return undefined; // success +} + +function signOut() { + delete getSession().proAccount; +} + +function authenticateTempSignIn(uid, tempPass) { + var emsg = "That password reset link that is no longer valid."; + + var account = getAccountById(uid); + if (!account) { + return emsg+" (Account not found.)"; + } + if (account.domainId != domains.getRequestDomainId()) { + return emsg+" (Wrong domain.)"; + } + if (!account.tempPassHash) { + return emsg+" (Expired.)"; + } + if (BCrypt.checkpw(tempPass, account.tempPassHash) != true) { + return emsg+" (Bad temp pass.)"; + } + + signInSession(account); + + getSession().accountMessage = "Please choose a new password"; + getSession().changePass = true; + + response.redirect("/ep/account/"); +} + +function signInSession(account) { + account.lastLoginDate = new Date(); + account.tempPassHash = null; + sqlobj.updateSingle('pro_accounts', {id: account.id}, account); + reloadSessionAccountData(account.id); + padusers.notifySignIn(); +} + +function listAllDomainAccounts(domainId) { + if (domainId === undefined) { + domainId = domains.getRequestDomainId(); + } + var records = sqlobj.selectMulti('pro_accounts', + {domainId: domainId, isDeleted: false}, {}); + return records; +} + +function listAllDomainAdmins(domainId) { + if (domainId === undefined) { + domainId = domains.getRequestDomainId(); + } + var records = sqlobj.selectMulti('pro_accounts', + {domainId: domainId, isDeleted: false, isAdmin: true}, + {}); + return records; +} + +function getActiveCount(domainId) { + var records = sqlobj.selectMulti('pro_accounts', + {domainId: domainId, isDeleted: false}, {}); + return records.length; +} + +/* getAccountById works for deleted and non-deleted accounts. + * The assumption is that cases whewre you look up an account by ID, you + * want the account info even if the account has been deleted. For + * example, when asking who created a pad. + */ +function getAccountById(accountId) { + var r = sqlobj.selectSingle('pro_accounts', {id: accountId}); + if (r) { + return r; + } else { + return undefined; + } +} + +/* getting an account by email only returns the account if it is + * not deleted. The assumption is that when you look up an account by + * email address, you only want active accounts. Furthermore, some + * deleted accounts may match a given email, but only one non-deleted + * account should ever match a single (email,domainId) pair. + */ +function getAccountByEmail(email, domainId) { + if (!domainId) { + domainId = domains.getRequestDomainId(); + } + var r = sqlobj.selectSingle('pro_accounts', {domainId: domainId, email: email, isDeleted: false}); + if (r) { + return r; + } else { + return undefined; + } +} + +function getFullNameById(id) { + if (!id) { + return null; + } + + return _withCache('names-by-id', function(cache) { + if (cache[id] === undefined) { + _dmesg("cache miss for getFullNameById (accountId="+id+")"); + var r = getAccountById(id); + if (r) { + cache[id] = r.fullName; + } else { + cache[id] = false; + } + } + if (cache[id]) { + return cache[id]; + } else { + return null; + } + }); +} + +function getTempSigninUrl(account, tempPass) { + return [ + 'https://', httpsHost(pro_utils.getFullProHost()), '/ep/account/sign-in?', + 'uid=', account.id, '&tp=', tempPass + ].join(''); +} + + +// TODO: this session account object storage / dirty cache is a +// ridiculous hack. What we should really do is have a caching/access +// layer for accounts similar to accessPad() and accessProPadMeta(), and +// have that abstraction take care of caching and marking accounts as +// dirty. This can be incorporated into getSessionProAccount(), and we +// should actually refactor that into accessSessionProAccount(). + +/* will force session data for this account to be updated next time that + * account requests a page. */ +function markDirtySessionAccount(uid) { + var domainId = domains.getRequestDomainId(); + + _withCache('dirty-session-accounts', function(cache) { + cache[uid] = true; + }); + _withCache('names-by-id', function(cache) { + delete cache[uid]; + }); + _withCache('does-domain-admin-exist', function(cache) { + delete cache[domainId]; + }); +} + +function reloadSessionAccountData(uid) { + if (!uid) { + uid = getSessionProAccount().id; + } + getSession().proAccount = getAccountById(uid); +} + +function getAllAccountsWithEmail(email) { + var accountRecords = sqlobj.selectMulti('pro_accounts', {email: email, isDeleted: false}, {}); + return accountRecords; +} + +function getEtherpadAdminAccount() { + return { + id: 0, + isAdmin: true, + fullName: "ETHERPAD ADMIN", + email: "support@pad.spline.inf.fu-berlin.de", + domainId: domains.getRequestDomainId(), + isDeleted: false + }; +} + +function getCachedActiveCount(domainId) { + return _withCache('user-counts.'+domainId, function(c) { + if (!c.count) { + c.count = getActiveCount(domainId); + } + return c.count; + }); +} + +function updateCachedActiveCount(domainId) { + _withCache('user-counts.'+domainId, function(c) { + c.count = getActiveCount(domainId); + }); +} + + + + + + diff --git a/trunk/etherpad/src/etherpad/pro/pro_config.js b/trunk/etherpad/src/etherpad/pro/pro_config.js new file mode 100644 index 0000000..d2d119f --- /dev/null +++ b/trunk/etherpad/src/etherpad/pro/pro_config.js @@ -0,0 +1,92 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("fastJSON"); +import("sqlbase.sqlobj"); +import("cache_utils.syncedWithCache"); + +import("etherpad.globals.*"); +import("etherpad.utils.*"); +import("etherpad.pro.domains"); +import("etherpad.pro.pro_utils"); + +function _guessSiteName() { + var x = request.host.split('.')[0]; + x = (x.charAt(0).toUpperCase() + x.slice(1)); + return x; +} + +function _getDefaultConfig() { + return { + siteName: _guessSiteName(), + alwaysHttps: false, + defaultPadText: renderTemplateAsString("misc/pad_default.ejs") + }; +} + +// must be fast! gets called per request, on every request. +function getConfig() { + if (!pro_utils.isProDomainRequest()) { + return null; + } + + if (!appjet.cache.pro_config) { + appjet.cache.pro_config = {}; + } + + var domainId = domains.getRequestDomainId(); + if (!appjet.cache.pro_config[domainId]) { + reloadConfig(); + } + + return appjet.cache.pro_config[domainId]; +} + +function reloadConfig() { + var domainId = domains.getRequestDomainId(); + var config = _getDefaultConfig(); + var records = sqlobj.selectMulti('pro_config', {domainId: domainId}, {}); + + records.forEach(function(r) { + var name = r.name; + var val = fastJSON.parse(r.jsonVal).x; + config[name] = val; + }); + + if (!appjet.cache.pro_config) { + appjet.cache.pro_config = {}; + } + + appjet.cache.pro_config[domainId] = config; +} + +function setConfigVal(name, val) { + var domainId = domains.getRequestDomainId(); + var jsonVal = fastJSON.stringify({x: val}); + + var r = sqlobj.selectSingle('pro_config', {domainId: domainId, name: name}); + if (!r) { + sqlobj.insert('pro_config', + {domainId: domainId, name: name, jsonVal: jsonVal}); + } else { + sqlobj.update('pro_config', + {name: name, domainId: domainId}, + {jsonVal: jsonVal}); + } + + reloadConfig(); +} + diff --git a/trunk/etherpad/src/etherpad/pro/pro_pad_db.js b/trunk/etherpad/src/etherpad/pro/pro_pad_db.js new file mode 100644 index 0000000..dbb412c --- /dev/null +++ b/trunk/etherpad/src/etherpad/pro/pro_pad_db.js @@ -0,0 +1,232 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("fastJSON"); +import("sqlbase.sqlobj"); +import("cache_utils.syncedWithCache"); +import("stringutils"); + +import("etherpad.pad.padutils"); +import("etherpad.collab.collab_server"); + +import("etherpad.pro.pro_pad_editors"); +import("etherpad.pro.domains"); +import("etherpad.pro.pro_accounts.getSessionProAccount"); + +jimport("java.lang.System.out.println"); + + +// TODO: actually implement the cache part + +// NOTE: must return a deep-CLONE of the actual record, because caller +// may proceed to mutate the returned record. + +function _makeRecord(r) { + if (!r) { + return null; + } + r.proAttrs = {}; + if (r.proAttrsJson) { + r.proAttrs = fastJSON.parse(r.proAttrsJson); + } + if (!r.proAttrs.editors) { + r.proAttrs.editors = []; + } + r.proAttrs.editors.sort(); + return r; +} + +function getSingleRecord(domainId, localPadId) { + // TODO: make clone + // TODO: use cache + var record = sqlobj.selectSingle('pro_padmeta', {domainId: domainId, localPadId: localPadId}); + return _makeRecord(record); +} + +function update(padRecord) { + // TODO: use cache + + padRecord.proAttrsJson = fastJSON.stringify(padRecord.proAttrs); + delete padRecord.proAttrs; + + sqlobj.update('pro_padmeta', {id: padRecord.id}, padRecord); +} + + +//-------------------------------------------------------------------------------- +// create/edit/destory events +//-------------------------------------------------------------------------------- + +function onCreatePad(pad) { + if (!padutils.isProPad(pad)) { return; } + + var data = { + domainId: padutils.getDomainId(pad.getId()), + localPadId: padutils.getLocalPadId(pad), + createdDate: new Date(), + }; + + if (getSessionProAccount()) { + data.creatorId = getSessionProAccount().id; + } + + sqlobj.insert('pro_padmeta', data); +} + +// Not a normal part of the UI. This is only called from admin interface, +// and thus should actually destroy all record of the pad. +function onDestroyPad(pad) { + if (!padutils.isProPad(pad)) { return; } + + sqlobj.deleteRows('pro_padmeta', { + domainId: padutils.getDomainId(pad.getId()), + localPadId: padutils.getLocalPadId(pad) + }); +} + +// Called within the context of a comet post. +function onEditPad(pad, padAuthorId) { + if (!padutils.isProPad(pad)) { return; } + + var editorId = undefined; + if (getSessionProAccount()) { + editorId = getSessionProAccount().id; + } + + if (!(editorId && (editorId > 0))) { + return; // etherpad admins + } + + pro_pad_editors.notifyEdit( + padutils.getDomainId(pad.getId()), + padutils.getLocalPadId(pad), + editorId, + new Date() + ); +} + +//-------------------------------------------------------------------------------- +// accessing the pad list. +//-------------------------------------------------------------------------------- + +function _makeRecordList(lis) { + lis.forEach(function(r) { + r = _makeRecord(r); + }); + return lis; +} + +function listMyPads() { + var domainId = domains.getRequestDomainId(); + var accountId = getSessionProAccount().id; + + var padlist = sqlobj.selectMulti('pro_padmeta', {domainId: domainId, creatorId: accountId, isDeleted: false, isArchived: false}); + return _makeRecordList(padlist); +} + +function listAllDomainPads() { + var domainId = domains.getRequestDomainId(); + var padlist = sqlobj.selectMulti('pro_padmeta', {domainId: domainId, isDeleted: false, isArchived: false}); + return _makeRecordList(padlist); +} + +function listArchivedPads() { + var domainId = domains.getRequestDomainId(); + var padlist = sqlobj.selectMulti('pro_padmeta', {domainId: domainId, isDeleted: false, isArchived: true}); + return _makeRecordList(padlist); +} + +function listPadsByEditor(editorId) { + editorId = Number(editorId); + var domainId = domains.getRequestDomainId(); + var padlist = sqlobj.selectMulti('pro_padmeta', {domainId: domainId, isDeleted: false, isArchived: false}); + padlist = _makeRecordList(padlist); + padlist = padlist.filter(function(p) { + // NOTE: could replace with binary search to speed things up, + // since we know that editors array is sorted. + return (p.proAttrs.editors.indexOf(editorId) >= 0); + }); + return padlist; +} + +function listLiveDomainPads() { + var thisDomainId = domains.getRequestDomainId(); + var allLivePadIds = collab_server.getAllPadsWithConnections(); + var livePadMap = {}; + + allLivePadIds.forEach(function(globalId) { + if (padutils.isProPadId(globalId)) { + var domainId = padutils.getDomainId(globalId); + var localId = padutils.globalToLocalId(globalId); + if (domainId == thisDomainId) { + livePadMap[localId] = true; + } + } + }); + + var padList = listAllDomainPads(); + padList = padList.filter(function(p) { + return (!!livePadMap[p.localPadId]); + }); + + return padList; +} + +//-------------------------------------------------------------------------------- +// misc utils +//-------------------------------------------------------------------------------- + + +function _withCache(name, fn) { + return syncedWithCache('pro-padmeta.'+name, fn); +} + +function _withDomainCache(domainId, name, fn) { + return _withCache(name+"."+domainId, fn); +} + + + +// returns the next pad ID to use for a newly-created pad on this domain. +function getNextPadId() { + var domainId = domains.getRequestDomainId(); + return _withDomainCache(domainId, 'padcounters', function(c) { + var ret; + if (c.x === undefined) { + c.x = _getLargestNumericPadId(domainId) + 1; + } + while (sqlobj.selectSingle('pro_padmeta', {domainId: domainId, localPadId: String(c.x)})) { + c.x++; + } + ret = c.x; + c.x++; + return ret; + }); +} + +function _getLargestNumericPadId(domainId) { + var max = 0; + var allPads = listAllDomainPads(); + allPads.forEach(function(p) { + if (stringutils.isNumeric(p.localPadId)) { + max = Math.max(max, Number(p.localPadId)); + } + }); + return max; +} + + + diff --git a/trunk/etherpad/src/etherpad/pro/pro_pad_editors.js b/trunk/etherpad/src/etherpad/pro/pro_pad_editors.js new file mode 100644 index 0000000..a90f05b --- /dev/null +++ b/trunk/etherpad/src/etherpad/pro/pro_pad_editors.js @@ -0,0 +1,104 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("execution"); +import("jsutils.*"); +import("cache_utils.syncedWithCache"); + +import("etherpad.pad.padutils"); +import("etherpad.pro.pro_padmeta"); +import("etherpad.log"); + +var _DOMAIN_EDIT_WRITE_INTERVAL = 2000; // 2 seconds + +function _withCache(name, fn) { + return syncedWithCache('pro-padmeta.'+name, fn); +} + +function _withDomainCache(domainId, name, fn) { + return _withCache(name+"."+domainId, fn); +} + + +function onStartup() { + execution.initTaskThreadPool("pro-padmeta-edits", 1); +} + +function onShutdown() { + var success = execution.shutdownAndWaitOnTaskThreadPool("pro-padmeta-edits", 4000); + if (!success) { + log.warn("Warning: pro.padmeta failed to flush pad edits on shutdown."); + } +} + +function notifyEdit(domainId, localPadId, editorId, editTime) { + if (!editorId) { + // guest editors + return; + } + _withDomainCache(domainId, "edits", function(c) { + if (!c[localPadId]) { + c[localPadId] = { + lastEditorId: editorId, + lastEditTime: editTime, + recentEditors: [] + }; + } + var info = c[localPadId]; + if (info.recentEditors.indexOf(editorId) < 0) { + info.recentEditors.push(editorId); + } + }); + _flushPadEditsEventually(domainId); +} + + +function _flushPadEditsEventually(domainId) { + // Make sure there is a recurring edit-writer for this domain + _withDomainCache(domainId, "recurring-edit-writers", function(c) { + if (!c[domainId]) { + flushEditsNow(domainId); + c[domainId] = true; + } + }); +} + +function flushEditsNow(domainId) { + if (!appjet.cache.shutdownHandlerIsRunning) { + execution.scheduleTask("pro-padmeta-edits", "proPadmetaFlushEdits", + _DOMAIN_EDIT_WRITE_INTERVAL, [domainId]); + } + + _withDomainCache(domainId, "edits", function(edits) { + var padIdList = keys(edits); + padIdList.forEach(function(localPadId) { + _writePadEditsToDbNow(domainId, localPadId, edits[localPadId]); + delete edits[localPadId]; + }); + }); +} + +function _writePadEditsToDbNow(domainId, localPadId, editInfo) { + var globalPadId = padutils.makeGlobalId(domainId, localPadId); + pro_padmeta.accessProPad(globalPadId, function(propad) { + propad.setLastEditedDate(editInfo.lastEditTime); + propad.setLastEditor(editInfo.lastEditorId); + editInfo.recentEditors.forEach(function(eid) { + propad.addEditor(eid); + }); + }); +} + diff --git a/trunk/etherpad/src/etherpad/pro/pro_padlist.js b/trunk/etherpad/src/etherpad/pro/pro_padlist.js new file mode 100644 index 0000000..73b179c --- /dev/null +++ b/trunk/etherpad/src/etherpad/pro/pro_padlist.js @@ -0,0 +1,289 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("funhtml.*"); +import("jsutils.*"); +import("stringutils"); + +import("etherpad.utils.*"); +import("etherpad.helpers"); +import("etherpad.pad.padutils"); +import("etherpad.collab.collab_server"); + +import("etherpad.pro.pro_accounts"); + +function _getColumnMeta() { + // returns map of {id --> { + // title, + // sortFn(a,b), + // render(p) + // } + + function _dateNum(d) { + if (!d) { + return 0; + } + return -1 * (+d); + } + + + var cols = {}; + + function addAvailableColumn(id, cdata) { + if (!cdata.render) { + cdata.render = function(p) { + return p[id]; + }; + } + if (!cdata.cmpFn) { + cdata.cmpFn = function(a,b) { + return cmp(a[id], b[id]); + }; + } + cdata.id = id; + cols[id] = cdata; + } + + addAvailableColumn('public', { + title: "", + render: function(p) { + // TODO: implement an icon with hover text that says public vs. + // private + return ""; + }, + cmpFn: function(a,b) { + return 0; // not sort-able + } + }); + addAvailableColumn('secure', { + title: "", + render: function(p) { + if (p.password) { + return IMG({src: '/static/img/may09/padlock.gif'}); + } else { + return ""; + } + }, + cmpFn: function(a,b) { + return cmp(a.password, b.password); + } + }); + addAvailableColumn('title', { + title: "Title", + render: function(p) { + var t = padutils.getProDisplayTitle(p.localPadId, p.title); + return A({href: "/"+p.localPadId}, t); + }, + sortFn: function(a, b) { + return cmp(padutils.getProDisplayTitle(a.localPadId, a.title), + padutils.getProDisplayTitle(b.localPadId, b.title)); + } + }); + addAvailableColumn('creatorId', { + title: "Creator", + render: function(p) { + return pro_accounts.getFullNameById(p.creatorId); + }, + sortFn: function(a, b) { + return cmp(pro_accounts.getFullNameById(a.creatorId), + pro_accounts.getFullNameById(b.creatorId)); + } + }); + addAvailableColumn('createdDate', { + title: "Created", + render: function(p) { + return timeAgo(p.createdDate); + }, + sortFn: function(a, b) { + return cmp(_dateNum(a.createdDate), _dateNum(b.createdDate)); + } + }); + addAvailableColumn('lastEditorId', { + title: "Last Editor", + render: function(p) { + if (p.lastEditorId) { + return pro_accounts.getFullNameById(p.lastEditorId); + } else { + return ""; + } + }, + sortFn: function(a, b) { + var a_ = a.lastEditorId ? pro_accounts.getFullNameById(a.lastEditorId) : "ZZZZZZZZZZ"; + var b_ = b.lastEditorId ? pro_accounts.getFullNameById(b.lastEditorId) : "ZZZZZZZZZZ"; + return cmp(a_, b_); + } + }); + + addAvailableColumn('editors', { + title: "Editors", + render: function(p) { + var editors = []; + p.proAttrs.editors.forEach(function(editorId) { + editors.push([editorId, pro_accounts.getFullNameById(editorId)]); + }); + editors.sort(function(a,b) { return cmp(a[1], b[1]); }); + var sp = SPAN(); + for (var i = 0; i < editors.length; i++) { + if (i > 0) { + sp.push(", "); + } + sp.push(A({href: "/ep/padlist/edited-by?editorId="+editors[i][0]}, editors[i][1])); + } + return sp; + } + }); + + addAvailableColumn('lastEditedDate', { + title: "Last Edited", + render: function(p) { + if (p.lastEditedDate) { + return timeAgo(p.lastEditedDate); + } else { + return "never"; + } + }, + sortFn: function(a,b) { + return cmp(_dateNum(a.lastEditedDate), _dateNum(b.lastEditedDate)); + } + }); + addAvailableColumn('localPadId', { + title: "Path", + }); + addAvailableColumn('actions', { + title: "", + render: function(p) { + return DIV({className: "gear-drop", id: "pad-gear-"+p.id}, " "); + } + }); + + addAvailableColumn('connectedUsers', { + title: "Connected Users", + render: function(p) { + var names = []; + padutils.accessPadLocal(p.localPadId, function(pad) { + var userList = collab_server.getConnectedUsers(pad); + userList.forEach(function(u) { + if (collab_server.translateSpecialKey(u.specialKey) != 'invisible') { + // excludes etherpad admin user + names.push(u.name); + } + }); + }); + return names.join(", "); + } + }); + + return cols; +} + +function _sortPads(padList) { + var meta = _getColumnMeta(); + var sortId = _getCurrentSortId(); + var reverse = false; + if (sortId.charAt(0) == '-') { + reverse = true; + sortId = sortId.slice(1); + } + padList.sort(function(a,b) { return cmp(a.localPadId, b.localPadId); }); + padList.sort(function(a,b) { return meta[sortId].sortFn(a, b); }); + if (reverse) { padList.reverse(); } +} + +function _addClientVars(padList) { + var padTitles = {}; // maps localPadId -> title + var localPadIds = {}; // maps padmetaId -> localPadId + padList.forEach(function(p) { + padTitles[p.localPadId] = stringutils.toHTML(padutils.getProDisplayTitle(p.localPadId, p.title)); + localPadIds[p.id] = p.localPadId; + }); + helpers.addClientVars({ + padTitles: padTitles, + localPadIds: localPadIds + }); +} + +function _getCurrentSortId() { + return request.params.sortBy || "lastEditedDate"; +} + +function _renderColumnHeader(m) { + var sp = SPAN(); + var sortBy = _getCurrentSortId(); + if (m.sortFn) { + var d = {sortBy: m.id}; + var arrow = ""; + if (sortBy == m.id) { + d.sortBy = ("-"+m.id); + arrow = html("↓"); + } + if (sortBy == ("-"+m.id)) { + arrow = html("↑"); + } + sp.push(arrow, " ", A({href: qpath(d)}, m.title)); + } else { + sp.push(m.title); + } + return sp; +} + +function renderPadList(padList, columnIds, limit) { + _sortPads(padList); + _addClientVars(padList); + + if (limit && (limit < padList.length)) { + padList = padList.slice(0,limit); + } + + var showSecurityInfo = false; + padList.forEach(function(p) { + if (p.password && p.password.length > 0) { showSecurityInfo = true; } + }); + if (!showSecurityInfo && (columnIds[0] == 'secure')) { + columnIds.shift(); + } + + var columnMeta = _getColumnMeta(); + + var t = TABLE({id: "padtable", cellspacing:"0", cellpadding:"0"}); + var toprow = TR({className: "toprow"}); + columnIds.forEach(function(cid) { + toprow.push(TH(_renderColumnHeader(columnMeta[cid]))); + }); + t.push(toprow); + + padList.forEach(function(p) { + // Note that this id is always numeric, and is the actual + // canonical padmeta id. + var row = TR({id: 'padmeta-'+p.id}); + var first = true; + for (var i = 0; i < columnIds.length; i++) { + var cid = columnIds[i]; + var m = columnMeta[cid]; + var classes = cid; + if (i == 0) { + classes += (" first"); + } + if (i == (columnIds.length - 1)) { + classes += (" last"); + } + row.push(TD({className: classes}, m.render(p))); + } + t.push(row); + }); + + return t; +} + diff --git a/trunk/etherpad/src/etherpad/pro/pro_padmeta.js b/trunk/etherpad/src/etherpad/pro/pro_padmeta.js new file mode 100644 index 0000000..6f911b2 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pro/pro_padmeta.js @@ -0,0 +1,111 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("stringutils"); +import("cache_utils.syncedWithCache"); +import("sync"); + +import("etherpad.pad.padutils"); +import("etherpad.pro.pro_pad_db"); + +function _doWithProPadLock(domainId, localPadId, func) { + var lockName = ["pro-pad", domainId, localPadId].join("/"); + return sync.doWithStringLock(lockName, func); +} + +function accessProPad(globalPadId, fn) { + // retrieve pad from cache + var domainId = padutils.getDomainId(globalPadId); + if (!domainId) { + throw Error("not a pro pad: "+globalPadId); + } + var localPadId = padutils.globalToLocalId(globalPadId); + var padRecord = pro_pad_db.getSingleRecord(domainId, localPadId); + + return _doWithProPadLock(domainId, localPadId, function() { + var isDirty = false; + + var proPad = { + exists: function() { return !!padRecord; }, + getDomainId: function() { return domainId; }, + getLocalPadId: function() { return localPadId; }, + getGlobalId: function() { return globalPadId; }, + getDisplayTitle: function() { return padutils.getProDisplayTitle(localPadId, padRecord.title); }, + setTitle: function(newTitle) { + padRecord.title = newTitle; + isDirty = true; + }, + isDeleted: function() { return padRecord.isDeleted; }, + markDeleted: function() { + padRecord.isDeleted = true; + isDirty = true; + }, + getPassword: function() { return padRecord.password; }, + setPassword: function(newPass) { + if (newPass == "") { + newPass = null; + } + padRecord.password = newPass; + isDirty = true; + }, + isArchived: function() { return padRecord.isArchived; }, + markArchived: function() { + padRecord.isArchived = true; + isDirty = true; + }, + unmarkArchived: function() { + padRecord.isArchived = false; + isDirty = true; + }, + setLastEditedDate: function(d) { + padRecord.lastEditedDate = d; + isDirty = true; + }, + addEditor: function(editorId) { + var es = String(editorId); + if (es && es.length > 0 && stringutils.isNumeric(editorId)) { + if (padRecord.proAttrs.editors.indexOf(editorId) < 0) { + padRecord.proAttrs.editors.push(editorId); + padRecord.proAttrs.editors.sort(); + } + isDirty = true; + } + }, + setLastEditor: function(editorId) { + var es = String(editorId); + if (es && es.length > 0 && stringutils.isNumeric(editorId)) { + padRecord.lastEditorId = editorId; + this.addEditor(editorId); + isDirty = true; + } + } + }; + + var ret = fn(proPad); + + if (isDirty) { + pro_pad_db.update(padRecord); + } + + return ret; + }); +} + +function accessProPadLocal(localPadId, fn) { + var globalPadId = padutils.getGlobalPadId(localPadId); + return accessProPad(globalPadId, fn); +} + diff --git a/trunk/etherpad/src/etherpad/pro/pro_quotas.js b/trunk/etherpad/src/etherpad/pro/pro_quotas.js new file mode 100644 index 0000000..ed69e1c --- /dev/null +++ b/trunk/etherpad/src/etherpad/pro/pro_quotas.js @@ -0,0 +1,141 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("funhtml.*"); +import("stringutils.startsWith"); +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon.inTransaction"); + +import("etherpad.billing.team_billing"); +import("etherpad.globals.*"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.domains"); +import("etherpad.sessions.getSession"); +import("etherpad.store.checkout"); + +function _createRecordIfNecessary(domainId) { + inTransaction(function() { + var r = sqlobj.selectSingle('pro_account_usage', {domainId: domainId}); + if (!r) { + var count = pro_accounts.getActiveCount(domainId); + sqlobj.insert('pro_account_usage', { + domainId: domainId, + count: count, + lastReset: (new Date), + lastUpdated: (new Date) + }); + } + }); +} + +/** + * Called after a successful payment has been made. + * Effect: counts the current number of domain accounts and stores that + * as the current account usage count. + */ +function resetAccountUsageCount(domainId) { + _createRecordIfNecessary(domainId); + var newCount = pro_accounts.getActiveCount(domainId); + sqlobj.update( + 'pro_account_usage', + {domainId: domainId}, + {count: newCount, lastUpdated: (new Date), lastReset: (new Date)} + ); +} + +/** + * Returns the max number of accounts that have existed simultaneously + * since the last reset. + */ +function getAccountUsageCount(domainId) { + _createRecordIfNecessary(domainId); + var record = sqlobj.selectSingle('pro_account_usage', {domainId: domainId}); + return record.count; +} + + +/** + * Updates the current account usage count by computing: + * usage_count = max(current_accounts, usage_count) + */ +function updateAccountUsageCount(domainId) { + _createRecordIfNecessary(domainId); + var record = sqlobj.selectSingle('pro_account_usage', {domainId: domainId}); + var currentCount = pro_accounts.getActiveCount(domainId); + var newCount = Math.max(record.count, currentCount); + sqlobj.update( + 'pro_account_usage', + {domainId: domainId}, + {count: newCount, lastUpdated: (new Date)} + ); +} + +// called per request + +function _generateGlobalBillingNotice(status) { + if (status == team_billing.CURRENT) { + return; + } + var notice = SPAN(); + if (status == team_billing.PAST_DUE) { + var suspensionDate = checkout.formatDate(team_billing.getDomainSuspensionDate(domains.getRequestDomainId())); + notice.push( + "Warning: your account is past due and will be suspended on ", + suspensionDate, "."); + } + if (status == team_billing.SUSPENDED) { + notice.push( + "Warning: your account is suspended because it is more than ", + team_billing.GRACE_PERIOD_DAYS, " days past due."); + } + + if (pro_accounts.isAdminSignedIn()) { + notice.push(" ", A({href: "/ep/admin/billing/"}, "Manage billing"), "."); + } else { + getSession().billingProblem = "Payment is required for sites with more than "+PRO_FREE_ACCOUNTS+" accounts."; + notice.push(" ", "Please ", + A({href: "/ep/payment-required"}, "contact a site administrator"), "."); + } + request.cache.globalProNotice = notice; +} + +function perRequestBillingCheck() { + // Do nothing if under the free account limit. + var activeAccounts = pro_accounts.getCachedActiveCount(domains.getRequestDomainId()); + if (activeAccounts <= PRO_FREE_ACCOUNTS) { + return; + } + + var status = team_billing.getDomainStatus(domains.getRequestDomainId()); + _generateGlobalBillingNotice(status); + + // now see if we need to block the request because of account + // suspension + if (status != team_billing.SUSPENDED) { + return; + } + // These path sare still OK if a suspension is on. + if ((startsWith(request.path, "/ep/account/") || + startsWith(request.path, "/ep/admin/") || + startsWith(request.path, "/ep/pro-help/") || + startsWith(request.path, "/ep/payment-required"))) { + return; + } + + getSession().billingProblem = "Payment is required for sites with more than "+PRO_FREE_ACCOUNTS+" accounts."; + response.redirect('/ep/payment-required'); +} + diff --git a/trunk/etherpad/src/etherpad/pro/pro_utils.js b/trunk/etherpad/src/etherpad/pro/pro_utils.js new file mode 100644 index 0000000..1dc2468 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pro/pro_utils.js @@ -0,0 +1,165 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("funhtml.*"); +import("stringutils.startsWith"); + +import("etherpad.utils.*"); +import("etherpad.globals.*"); +import("etherpad.log"); +import("etherpad.pne.pne_utils"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_accounts.getSessionProAccount"); +import("etherpad.pro.domains"); +import("etherpad.pro.pro_quotas"); +import("etherpad.sessions"); +import("etherpad.sessions.getSession"); + +import("etherpad.control.pro.pro_main_control"); + +jimport("java.lang.System.out.println"); + +function _stripComet(x) { + if (x.indexOf('.comet.') > 0) { + x = x.split('.comet.')[1]; + } + return x; +} + +function getProRequestSubdomain() { + var d = _stripComet(request.domain); + return d.split('.')[0]; +} + +function getRequestSuperdomain() { + var parts = request.domain.split('.'); + while (parts.length > 0) { + var domain = parts.join('.'); + if (SUPERDOMAINS[domain]) { + return domain; + } + parts.shift(); // Remove next level + } +} + +function isProDomainRequest() { + // the result of this function never changes within the same request. + var c = appjet.requestCache; + if (c.isProDomainRequest === undefined) { + c.isProDomainRequest = _computeIsProDomainRequest(); + } + return c.isProDomainRequest; +} + +function _computeIsProDomainRequest() { + if (pne_utils.isPNE()) { + return true; + } + + var domain = _stripComet(request.domain); + + if (SUPERDOMAINS[domain]) { + return false; + } + + var requestSuperdomain = getRequestSuperdomain(); + + if (SUPERDOMAINS[requestSuperdomain]) { + // now see if this subdomain is actually in our database. + if (domains.getRequestDomainRecord()) { + return true; + } else { + return false; + } + } + + return false; +} + +function preDispatchAccountCheck() { + // if account is not logged in, redirect to /ep/account/login + // + // if it's PNE and there is no admin account, allow them to create an admin + // account. + + if (pro_main_control.isActivationAllowed()) { + return; + } + + if (!pro_accounts.doesAdminExist()) { + if (request.path != '/ep/account/create-admin-account') { + // should only happen for eepnet installs + response.redirect('/ep/account/create-admin-account'); + } + } else { + pro_accounts.requireAccount(); + } + + pro_quotas.perRequestBillingCheck(); +} + +function renderFramedMessage(m) { + renderFramedHtml( + DIV( + {style: "font-size: 2em; padding: 2em; margin: 4em; border: 1px solid #ccc; background: #e6e6e6;"}, + m)); +} + +function getFullProDomain() { + // TODO: have a special config param for this? --etherpad.canonicalDomain + return request.domain; +} + +// domain, including port if necessary +function getFullProHost() { + var h = getFullProDomain(); + var parts = request.host.split(':'); + if (parts.length > 1) { + h += (':' + parts[1]); + } + return h; +} + +function getFullSuperdomainHost() { + if (isProDomainRequest()) { + var h = getRequestSuperdomain() + var parts = request.host.split(':'); + if (parts.length > 1) { + h += (':' + parts[1]); + } + return h; + } else { + return request.host; + } +} + +function getEmailFromAddr() { + var fromDomain = 'pad.spline.inf.fu-berlin.de'; + if (pne_utils.isPNE()) { + fromDomain = getFullProDomain(); + } + return ('"EtherPad" <noreply@'+fromDomain+'>'); +} + +function renderGlobalProNotice() { + if (request.cache.globalProNotice) { + return DIV({className: 'global-pro-notice'}, + request.cache.globalProNotice); + } else { + return ""; + } +} + diff --git a/trunk/etherpad/src/etherpad/quotas.js b/trunk/etherpad/src/etherpad/quotas.js new file mode 100644 index 0000000..7e939ec --- /dev/null +++ b/trunk/etherpad/src/etherpad/quotas.js @@ -0,0 +1,50 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("stringutils"); +import("etherpad.licensing"); +import("etherpad.utils.*"); +import("etherpad.pne.pne_utils"); + +// TODO: hook into PNE? + +function getMaxSimultaneousPadEditors(globalPadId) { + if (isProDomainRequest()) { + if (pne_utils.isPNE()) { + return licensing.getMaxUsersPerPad(); + } else { + return 1e6; + } + } else { + // pad.spline.inf.fu-berlin.de public pads + if (globalPadId && stringutils.startsWith(globalPadId, "conf-")) { + return 64; + } else { + return 16; + } + } + return 1e6; +} + +function getMaxSavedRevisionsPerPad() { + if (isProDomainRequest()) { + return 1e3; + } else { + // free public pad.spline.inf.fu-berlin.de + return 100; + } +} + diff --git a/trunk/etherpad/src/etherpad/sessions.js b/trunk/etherpad/src/etherpad/sessions.js new file mode 100644 index 0000000..c218da8 --- /dev/null +++ b/trunk/etherpad/src/etherpad/sessions.js @@ -0,0 +1,203 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sessions"); +import("stringutils.randomHash"); +import("funhtml.*"); + +import("etherpad.log"); +import("etherpad.globals.*"); +import("etherpad.pro.pro_utils"); +import("etherpad.utils.*"); +import("cache_utils.syncedWithCache"); + +jimport("java.lang.System.out.println"); + +var _TRACKING_COOKIE_NAME = "ET"; +var _SESSION_COOKIE_NAME = "ES"; + +function _updateInitialReferrer(data) { + + if (data.initialReferer) { + return; + } + + var ref = request.headers["Referer"]; + + if (!ref) { + return; + } + if (ref.indexOf('http://'+request.host) == 0) { + return; + } + if (ref.indexOf('https://'+request.host) == 0) { + return; + } + + data.initialReferer = ref; + log.custom("referers", {referer: ref}); +} + +function _getScopedDomain(subDomain) { + var d = request.domain; + if (d.indexOf(".") == -1) { + // special case for "localhost". For some reason, firefox does not like cookie domains + // to be ".localhost". + return undefined; + } + if (subDomain) { + d = subDomain + "." + d; + } + return "." + d; +} +//-------------------------------------------------------------------------------- + +// pass in subDomain to get the session data for a particular subdomain -- +// intended for debugging. +function getSession(subDomain) { + var sessionData = sessions.getSession({ + cookieName: _SESSION_COOKIE_NAME, + domain: _getScopedDomain(subDomain) + }); + _updateInitialReferrer(sessionData); + return sessionData; +} + +function getSessionId() { + return sessions.getSessionId(_SESSION_COOKIE_NAME, false, _getScopedDomain()); +} + +function _getGlobalSessionId() { + return (request.isDefined && request.cookies[_SESSION_COOKIE_NAME]) || null; +} + +function isAnEtherpadAdmin() { + var sessionId = _getGlobalSessionId(); + if (! sessionId) { + return false; + } + + return syncedWithCache("isAnEtherpadAdmin", function(c) { + return !! c[sessionId]; + }); +} + +function setIsAnEtherpadAdmin(v) { + var sessionId = _getGlobalSessionId(); + if (! sessionId) { + return; + } + + syncedWithCache("isAnEtherpadAdmin", function(c) { + if (v) { + c[sessionId] = true; + } + else { + delete c[sessionId]; + } + }); +} + +//-------------------------------------------------------------------------------- + +function setTrackingCookie() { + if (request.cookies[_TRACKING_COOKIE_NAME]) { + return; + } + + var trackingVal = randomHash(16); + var expires = new Date(32503708800000); // year 3000 + + response.setCookie({ + name: _TRACKING_COOKIE_NAME, + value: trackingVal, + path: "/", + domain: _getScopedDomain(), + expires: expires + }); +} + +function getTrackingId() { + // returns '-' if no tracking ID (caller can assume) + return (request.cookies[_TRACKING_COOKIE_NAME] || response.getCookie(_TRACKING_COOKIE_NAME) || '-'); +} + +//-------------------------------------------------------------------------------- + +function preRequestCookieCheck() { + if (isStaticRequest()) { + return; + } + + // If this function completes without redirecting, then it means + // there is a valid session cookie and tracking cookie. + + if (request.cookies[_SESSION_COOKIE_NAME] && + request.cookies[_TRACKING_COOKIE_NAME]) { + + if (request.params.cookieShouldBeSet) { + response.redirect(qpath({cookieShouldBeSet: null})); + } + return; + } + + // Only superdomains can set cookies. + var isSuperdomain = SUPERDOMAINS[request.domain]; + + if (isSuperdomain) { + // superdomain without cookies + + getSession(); + setTrackingCookie(); + + // check if we need to redirect back to a subdomain. + if ((request.path == "/") && + (request.params.setCookie) && + (request.params.contUrl)) { + + var contUrl = request.params.contUrl; + if (contUrl.indexOf("?") == -1) { + contUrl += "?"; + } + contUrl += "&cookieShouldBeSet=1"; + response.redirect(contUrl); + } + } else { + var parts = request.domain.split("."); + if (parts.length < 3) { + // invalid superdomain + response.write("invalid superdomain"); + response.stop(); + } + // subdomain without cookies + if (request.params.cookieShouldBeSet) { + log.warn("Cookie failure!"); + renderFramedHtml(DIV({style: "border: 1px solid #ccc; padding: 1em; width: 600px; margin: 1em auto; font-size: 1.4em;"}, + P("Please enable cookies in your browser in order to access this site."), + BR(), + P(A({href: "/"}, "Continue")))); + response.stop(); + } else { + var contUrl = request.url; + var p = request.host.split(':')[1]; + p = (p ? (":"+p) : ""); + response.redirect(request.scheme+"://"+pro_utils.getRequestSuperdomain()+p+ + "/?setCookie=1&contUrl="+encodeURIComponent(contUrl)); + } + } +} + + diff --git a/trunk/etherpad/src/etherpad/statistics/exceptions.js b/trunk/etherpad/src/etherpad/statistics/exceptions.js new file mode 100644 index 0000000..723085d --- /dev/null +++ b/trunk/etherpad/src/etherpad/statistics/exceptions.js @@ -0,0 +1,231 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import("fastJSON"); +import("etherpad.log"); +import("cache_utils.syncedWithCache"); +import("funhtml.*"); +import("jsutils.{eachProperty,keys}"); + +function _dayKey(date) { + return [date.getFullYear(), date.getMonth()+1, date.getDate()].join(','); +} + +function _dateAddDays(date, numDays) { + return new Date((+date) + numDays*1000*60*60*24); +} + +function _loadDay(date) { + var fileName = log.frontendLogFileName('exception', date); + if (! fileName) { + return []; + } + var reader = new java.io.BufferedReader(new java.io.FileReader(fileName)); + var line = null; + var array = []; + while ((line = reader.readLine()) !== null) { + array.push(fastJSON.parse(line)); + } + return array; +} + +function _accessLatestLogs(func) { + syncedWithCache("etherpad.statistics.exceptions", function(exc) { + if (! exc.byDay) { + exc.byDay = {}; + } + // always reload today from disk + var now = new Date(); + var today = now; + var todayKey = _dayKey(today); + exc.byDay[todayKey] = _loadDay(today); + var activeKeys = {}; + activeKeys[todayKey] = true; + // load any of 7 previous days that aren't loaded or + // were not loaded as a historical day + for(var i=1;i<=7;i++) { + var pastDay = _dateAddDays(today, -i); + var pastDayKey = _dayKey(pastDay); + activeKeys[pastDayKey] = true; + if ((! exc.byDay[pastDayKey]) || (! exc.byDay[pastDayKey].sealed)) { + exc.byDay[pastDayKey] = _loadDay(pastDay); + exc.byDay[pastDayKey].sealed = true; // in the past, won't change + } + } + // clear old days + for(var k in exc.byDay) { + if (! (k in activeKeys)) { + delete exc.byDay[k]; + } + } + + var logs = { + getDay: function(daysAgo) { + return exc.byDay[_dayKey(_dateAddDays(today, -daysAgo))]; + }, + eachLineInLastNDays: function(n, func) { + var oldest = _dateAddDays(now, -n); + var oldestNum = +oldest; + for(var i=n;i>=0;i--) { + var lines = logs.getDay(i); + lines.forEach(function(line) { + if (line.date > oldestNum) { + func(line); + } + }); + } + } + }; + + func(logs); + }); +} + +function _exceptionHash(line) { + // skip the first line of jsTrace, take hashCode of rest + var trace = line.jsTrace; + var stack = trace.substring(trace.indexOf('\n') + 1); + return new java.lang.String(stack).hashCode(); +} + +// Used to take a series of strings and produce an array of +// [common prefix, example middle, common suffix], or +// [string] if the strings are the same. Takes oldInfo +// and returns newInfo; each is either null or an array +// of length 1 or 3. +function _accumCommonPrefixSuffix(oldInfo, newString) { + function _commonPrefixLength(a, b) { + var x = 0; + while (x < a.length && x < b.length && a.charAt(x) == b.charAt(x)) { + x++; + } + return x; + } + + function _commonSuffixLength(a, b) { + var x = 0; + while (x < a.length && x < b.length && + a.charAt(a.length-1-x) == b.charAt(b.length-1-x)) { + x++; + } + return x; + } + + if (! oldInfo) { + return [newString]; + } + else if (oldInfo.length == 1) { + var oldString = oldInfo[0]; + if (oldString == newString) { + return oldInfo; + } + var newInfo = []; + var a = _commonPrefixLength(oldString, newString); + newInfo[0] = newString.substring(0, a); + oldString = oldString.substring(a); + newString = newString.substring(a); + var b = _commonSuffixLength(oldString, newString); + newInfo[2] = newString.slice(-b); + oldString = oldString.slice(0, -b); + newString = newString.slice(0, -b); + newInfo[1] = newString; + return newInfo; + } + else { + // oldInfo.length == 3 + var a = _commonPrefixLength(oldInfo[0], newString); + var b = _commonSuffixLength(oldInfo[2], newString); + return [newString.slice(0, a), newString.slice(a, -b), + newString.slice(-b)]; + } +} + +function render() { + + _accessLatestLogs(function(logs) { + var weekCounts = {}; + var totalWeekCount = 0; + + // count exceptions of each kind in last week + logs.eachLineInLastNDays(7, function(line) { + var hash = _exceptionHash(line); + weekCounts[hash] = (weekCounts[hash] || 0) + 1; + totalWeekCount++; + }); + + var dayData = {}; + var totalDayCount = 0; + + // accumulate data about each exception in last 24 hours + logs.eachLineInLastNDays(1, function(line) { + var hash = _exceptionHash(line); + var oldData = dayData[hash]; + var data = (oldData || {}); + if (! oldData) { + data.hash = hash; + data.trace = line.jsTrace.substring(line.jsTrace.indexOf('\n')+1); + data.trackers = {}; + } + var msg = line.jsTrace.substring(0, line.jsTrace.indexOf('\n')); + data.message = _accumCommonPrefixSuffix(data.message, msg); + data.count = (data.count || 0)+1; + data.trackers[line.tracker] = true; + totalDayCount++; + dayData[hash] = data; + }); + + // put day datas in an array and sort + var dayDatas = []; + eachProperty(dayData, function(k,v) { + dayDatas.push(v); + }); + dayDatas.sort(function(a, b) { + return b.count - a.count; + }); + + // process + dayDatas.forEach(function(data) { + data.weekCount = (weekCounts[data.hash] || 0); + data.numTrackers = keys(data.trackers).length; + }); + + // gen HTML + function num(n) { return SPAN({className:'num'}, n); } + + response.write(STYLE(html(".trace { height: 300px; overflow: auto; background: #eee; margin-left: 1em; font-family: monospace; border: 1px solid #833; padding: 4px; }\n"+ + ".exc { margin: 1em 0; }\n"+ + ".num { font-size: 150%; }"))); + + response.write(P("Total exceptions in past day: ", num(totalDayCount), + ", past week: ", totalWeekCount)); + + response.write(P(SMALL(EM("Data on this page is live.")))); + + response.write(H2("Exceptions grouped by stack trace:")); + + dayDatas.forEach(function(data) { + response.write(DIV({className:'exc'}, + 'Past day: ',num(data.count),', Past week: ', + data.weekCount,', Different tracker cookies today: ', + data.numTrackers, + '\n',data.message[0], + (data.message[1] && I(data.message[1])) || '', + (data.message[2] || ''),'\n', + DIV({className:'trace'}, data.trace))); + }); + }); +} diff --git a/trunk/etherpad/src/etherpad/statistics/statistics.js b/trunk/etherpad/src/etherpad/statistics/statistics.js new file mode 100644 index 0000000..8174405 --- /dev/null +++ b/trunk/etherpad/src/etherpad/statistics/statistics.js @@ -0,0 +1,1248 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("dateutils.noon"); +import("execution"); +import("exceptionutils"); +import("fastJSON"); +import("fileutils.fileLineIterator"); +import("jsutils.*"); +import("sqlbase.sqlobj"); + +import("etherpad.log"); + +jimport("net.appjet.oui.GenericLoggerUtils"); +jimport("net.appjet.oui.LoggableFromJson"); +jimport("net.appjet.oui.FilterWrangler"); +jimport("java.lang.System.out.println"); +jimport("net.appjet.common.util.ExpiringMapping"); + +var millisInDay = 86400*1000; + +function _stats() { + if (! appjet.cache.statistics) { + appjet.cache.statistics = {}; + } + return appjet.cache.statistics; +} + +function onStartup() { + execution.initTaskThreadPool("statistics", 1); + _scheduleNextDailyUpdate(); + + onReset(); +} + +function _info(m) { + log.info({type: 'statistics', message: m}); +} + +function _warn(m) { + log.info({type: 'statistics', message: m}); +} + +function _statData() { + return _stats().stats; +} + +function getAllStatNames() { + return keys(_statData()); +} + +function getStatData(statName) { + return _statData()[statName]; +} + +function _setStatData(statName, data) { + _statData()[statName] = data; +} + +function liveSnapshot(stat) { + var statObject; + if (typeof(stat) == 'string') { + // "stat" is the stat name. + statObject = getStatData(stat); + } else if (typeof(stat) == 'object') { + statObject = stat; + } else { + return; + } + return _callFunction(statObject.snapshot_f, + statObject.name, statObject.options, statObject.data); +} + +// ------------------------------------------------------------------ +// stats processing +// ------------------------------------------------------------------ + +// some useful constants +var LIVE = 'live'; +var HIST = 'historical'; +var HITS = 'hits'; +var UNIQ = 'uniques'; +var VALS = 'values'; +var HGRM = 'histogram'; + +// helpers + +function _date(d) { + return new Date(d); +} + +function _saveStat(day, name, value) { + var timestamp = Math.floor(day.valueOf() / 1000); + _info({statistic: name, + timestamp: timestamp, + value: value}); + try { + sqlobj.insert('statistics', { + name: name, + timestamp: timestamp, + value: fastJSON.stringify(value) + }); + } catch (e) { + var msg; + try { + msg = e.getMessage(); + } catch (e2) { + try { + msg = e.toSource(); + } catch (e3) { + msg = "(none)"; + } + } + _warn("failed to save stat "+name+": "+msg); + } +} + +function _convertScalaTopValuesToJs(topValues) { + var totalValue = topValues._1(); + var countsMap = topValues._2(); + countsObj = {}; + countsMap.foreach(scalaF1(function(pair) { countsObj[pair._1()] = pair._2(); })); + return {total: totalValue, counts: countsObj}; +} + +function _fakeMap() { + var map = {} + return { + get: function(k) { return map[k]; }, + put: function(k, v) { map[k] = v; }, + remove: function(k) { delete map[k]; } + } +} + +function _withinSecondsOf(numSeconds, t1, t2) { + return (t1 > t2-numSeconds*1000) && (t1 < t2+numSeconds*1000); +} + +function _callFunction(functionName, arg1, arg2, etc) { + var f = this[functionName]; + var args = Array.prototype.slice.call(arguments, 1); + return f.apply(this, args); +} + +// trackers and other init functions + +function _hitTracker(trackerType, timescaleType) { + var className; + switch (trackerType) { + case HITS: className = "BucketedLastHits"; break; + case UNIQ: className = "BucketedUniques"; break; + case VALS: className = "BucketedValueCounts"; break; + case HGRM: className = "BucketedLastHitsHistogram"; break; + } + var tracker; + switch (timescaleType) { + case LIVE: + tracker = new net.appjet.oui[className](24*60*60*1000); + break; + case HIST: + // timescale just needs to be longer than a day. + tracker = new net.appjet.oui[className](365*24*60*60*1000, true); + break; + } + + var conversionData = { + total_f: "count", + history_f: "history", + latest_f: "latest", + }; + switch (trackerType) { + case HITS: case UNIQ: + conversionData.conversionFunction = + function(x) { return x; } // no conversion necessary. + break; + case VALS: + conversionData.conversionFunction = _convertScalaTopValuesToJs + break; + case HGRM: + conversionData.conversionFunction = + function(hFunc) { return function(pct) { return hFunc.apply(pct); } } + break; + } + + + return { + tracker: tracker, + conversionData: conversionData, + hit: function(d, n1, n2) { + d = _date(d); + if (n2 === undefined) { + this.tracker.hit(d, n1); + } else { + this.tracker.hit(d, n1, n2); + } + }, + get total() { + return this.conversionData.conversionFunction(this.tracker[this.conversionData.total_f]()); + }, + history: function(bucketsPerSample, numSamples) { + var scalaArray = this.tracker[this.conversionData.history_f](bucketsPerSample, numSamples); + var jsArray = []; + for (var i = 0; i < scalaArray.length(); ++i) { + jsArray.push(this.conversionData.conversionFunction(scalaArray.apply(i))); + } + return jsArray; + }, + latest: function(bucketsPerSample) { + return this.conversionData.conversionFunction(this.tracker[this.conversionData.latest_f](bucketsPerSample)); + } + } +} + +function _initCount(statName, options, timescaleType) { + return _hitTracker(HITS, timescaleType); +} +function _initUniques(statName, options, timescaleType) { + return _hitTracker(UNIQ, timescaleType); +} +function _initTopValues(statName, options, timescaleType) { + return _hitTracker(VALS, timescaleType); +} +function _initHistogram(statName, options, timescaleType) { + return _hitTracker(HGRM, timescaleType); +} + +function _initLatencies(statName, options, type) { + var hits = _initTopValues(statName, options, type); + var latencies = _initTopValues(statName, options, type); + + return { + hit: function(d, value, latency) { + hits.hit(d, value); + latencies.hit(d, value, latency); + }, + hits: hits, + latencies: latencies + } +} + +function _initDisconnectTracker(statName, options, timescaleType) { + return { + map: (timescaleType == LIVE ? new ExpiringMapping(60*1000) : _fakeMap()), + counter: _initCount(statName, options, timescaleType), + uniques: _initUniques(statName, options, timescaleType), + isLive: timescaleType == LIVE + } +} + +// update functions + +function _updateCount(statName, options, logName, data, logObject) { + // println("update count: "+statName+" on log "+logName+", with data: "+data.toSource()+" with log entry: "+logObject.toSource()); + if (options.filter == null || options.filter(logObject)) { + data.hit(logObject.date, 1); + } +} + +function _updateSum(statName, options, logName, data, logObject) { + // println("update sum: "+statName+" on log "+logName+", with data: "+data.toSource()+" with log entry: "+logObject.toSource()); + if (options.filter == null || options.filter(logObject)) { + data.hit(logObject.date, Math.round(Number(logObject[options.fieldName]))); + } +} + +function _updateUniquenessCount(statName, options, logName, data, logObject) { + // println("update uniqueness: "+statName+" on log "+logName+", with data: "+data.toSource()+" with log entry: "+logObject.toSource()); + if (options.filter == null || options.filter(logObject)) { + var value = logObject[options.fieldName]; + if (value === undefined) { return; } + data.hit(logObject.date, value); + } +} + +function _updateTopValues(statName, options, logName, data, logObject) { + // println("update topvalues: "+statName+" on log "+logName+", with data: "+data.toSource()+" with log entry: "+logObject.toSource()); + + if (options.filter == null || options.filter(logObject)) { + var value = logObject[options.fieldName]; + if (value === undefined) { return; } + if (options.canonicalizer) { + value = options.canonicalizer(value); + } + data.hit(logObject.date, value); + } +} + +function _updateLatencies(statName, options, logName, data, logObject) { + // println("update latencies: "+statName+" on log "+logName+", with data: "+data.toSource()+" with log entry: "+logObject.toSource()); + + if (options.filter == null || options.filter(logObject)) { + var value = logObject[options.fieldName]; + var latency = logObject[options.latencyFieldName]; + if (value === undefined) { return; } + data.hit(logObject.date, value, latency); + } +} + +function _updateDisconnectTracker(statName, options, logName, data, logObject) { + if (logName == "frontend/padevents" && logObject.type != "userleave") { + // we only care about userleaves from the padevents log. + return; + } + + var [evtPrefix, otherPrefix] = + (logName == "frontend/padevents" ? ["l-", "d-"] : ["d-", "l-"]); + var dateLong = logObject.date; + var userId = logObject.session; + + var lastOtherEvent = data.map.get(otherPrefix+userId); + if (lastOtherEvent != null && _withinSecondsOf(60, dateLong, lastOtherEvent.date)) { + data.counter.hit(logObject.date, 1); + data.uniques.hit(logObject.date, userId); + data.map.remove(otherPrefix+userId); + if (data.isLive) { + log.custom("avoidable_disconnects", + {userId: userId, + errorMessage: lastOtherEvent.errorMessage || logObject.errorMessage}); + } + } else { + data.map.put(evtPrefix+userId, {date: dateLong, message: logObject.errorMessage}); + } +} + +// snapshot functions + +function _lazySnapshot(snapshot) { + var total; + var history = {}; + var latest = {}; + return { + get total() { + if (total === undefined) { + total = snapshot.total; + } + return total; + }, + history: function(bucketsPerSample, numSamples) { + if (history[""+bucketsPerSample+":"+numSamples] === undefined) { + history[""+bucketsPerSample+":"+numSamples] = snapshot.history(bucketsPerSample, numSamples); + } + return history[""+bucketsPerSample+":"+numSamples]; + }, + latest: function(bucketsPerSample) { + if (latest[""+bucketsPerSample] === undefined) { + latest[""+bucketsPerSample] = snapshot.latest(bucketsPerSample); + } + return latest[""+bucketsPerSample]; + } + } +} + +function _snapshotTotal(statName, options, data) { + return _lazySnapshot(data); +} + +function _convertTopValue(topValue) { + var counts = topValue.counts; + var sortedValues = keys(counts).sort(function(x, y) { + return counts[y] - counts[x]; + }).map(function(key) { + return { value: key, count: counts[key] }; + }); + return {count: topValue.total, topValues: sortedValues.slice(0, 50) }; +} + +function _snapshotTopValues(statName, options, data) { + var convertedData = {}; + + return _lazySnapshot({ + get total() { + return _convertTopValue(data.total); + }, + history: function(bucketsPerSample, numSamples) { + return data.history(bucketsPerSample, numSamples).map(_convertTopValue); + }, + latest: function(bucketsPerSample) { + return _convertTopValue(data.latest(bucketsPerSample)); + } + }); +} + +function _snapshotLatencies(statName, options, data) { + // convert the hits + total latencies into a topValues-style data object. + var hits = data.hits; + var totalLatencies = data.latencies; + + function convertCountsObjects(latencyCounts, hitCounts) { + var mergedCounts = {} + keys(latencyCounts.counts).forEach(function(value) { + mergedCounts[value] = + Math.round(latencyCounts.counts[value] / (hitCounts.counts[value] || 1)); + }); + return {counts: mergedCounts, total: latencyCounts.total / (hitCounts.total || 1)}; + } + + // ...and then convert that object into a snapshot. + return _snapshotTopValues(statName, options, { + get total() { + return convertCountsObjects(totalLatencies.total, hits.total); + }, + history: function(bucketsPerSample, numSamples) { + return mergeArrays( + convertCountsObjects, + totalLatencies.history(bucketsPerSample, numSamples), + hits.history(bucketsPerSample, numSamples)); + }, + latest: function(bucketsPerSample) { + return convertCountsObjects(totalLatencies.latest(bucketsPerSample), hits.latest(bucketsPerSample)); + } + }); +} + +function _snapshotDisconnectTracker(statName, options, data) { + var topValues = {}; + var counts = data.counter; + var uniques = data.uniques; + function topValue(counts, uniques) { + return { + count: counts, + topValues: [{value: "total_disconnects", count: counts}, + {value: "disconnected_userids", count: uniques}] + } + } + return _lazySnapshot({ + get total() { + return topValue(counts.total, uniques.total); + }, + history: function(bucketsPerSample, numSamples) { + return mergeArrays( + topValue, + counts.history(bucketsPerSample, numSamples), + uniques.history(bucketsPerSample, numSamples)); + }, + latest: function(bucketsPerSample) { + return topValue(counts.latest(bucketsPerSample), uniques.latest(bucketsPerSample)); + } + }); +} + +function _generateLogInterestMap(statNames) { + var interests = {}; + statNames.forEach(function(statName) { + var logs = getStatData(statName).logNames; + logs.forEach(function(logName) { + if (! interests[logName]) { + interests[logName] = {}; + } + interests[logName][statName] = true; + }); + }); + return interests; +} + + +// ------------------------------------------------------------------ +// stat generators +// ------------------------------------------------------------------ + +// statSpec has these properties +// name +// dataType - line, topvalues, histogram, etc. +// logNames +// init_f +// update_f +// snapshot_f +// options - object containing any additional data, passed in to to the various functions. + +// init_f gets (statName, options, "live"|"historical") +// update_f gets (statName, options, logName, data, logObject) +// snapshot_f gets (statName, options, data) +function addStat(statSpec) { + var statName = statSpec.name; + if (! getStatData(statName)) { + var initialData = + _callFunction(statSpec.init_f, statName, statSpec.options, LIVE); + _setStatData(statName, { + data: initialData, + }); + } + + var s = getStatData(statName); + + s.options = statSpec.options; + s.name = statName; + s.logNames = statSpec.logNames; + s.dataType = statSpec.dataType; + s.historicalDays = ("historicalDays" in statSpec ? statSpec.historicalDays : 1); + + s.init_f = statSpec.init_f; + s.update_f = statSpec.update_f; + s.snapshot_f = statSpec.snapshot_f; + + function registerInterest(logName) { + if (! _stats().logNamesToInterestedStatNames[logName]) { + _stats().logNamesToInterestedStatNames[logName] = {}; + } + _stats().logNamesToInterestedStatNames[logName][statName] = true; + } + statSpec.logNames.forEach(registerInterest); +} + +function addSimpleCount(statName, historicalDays, logName, filter) { + addStat({ + name: statName, + dataType: "line", + logNames: (logName instanceof Array ? logName : [logName]), + init_f: "_initCount", + update_f: "_updateCount", + snapshot_f: "_snapshotTotal", + options: { filter: filter }, + historicalDays: historicalDays || 1 + }); +} + +function addSimpleSum(statName, historicalDays, logName, filter, fieldName) { + addStat({ + name: statName, + dataType: "line", + logNames: (logName instanceof Array ? logName : [logName]), + init_f: "_initCount", + update_f: "_updateSum", + snapshot_f: "_snapshotTotal", + options: { filter: filter, fieldName: fieldName }, + historicalDays: historicalDays || 1 + }); +} + +function addUniquenessCount(statName, historicalDays, logName, filter, fieldName) { + addStat({ + name: statName, + dataType: "line", + logNames: (logName instanceof Array ? logName : [logName]), + init_f: "_initUniques", + update_f: "_updateUniquenessCount", + snapshot_f: "_snapshotTotal", + options: { filter: filter, fieldName: fieldName }, + historicalDays: historicalDays || 1 + }) +} + +function addTopValuesStat(statName, historicalDays, logName, filter, fieldName, canonicalizer) { + addStat({ + name: statName, + dataType: "topValues", + logNames: (logName instanceof Array ? logName : [logName]), + init_f: "_initTopValues", + update_f: "_updateTopValues", + snapshot_f: "_snapshotTopValues", + options: { filter: filter, fieldName: fieldName, canonicalizer: canonicalizer }, + historicalDays: historicalDays || 1 + }); +} + +function addLatenciesStat(statName, historicalDays, logName, filter, fieldName, latencyFieldName) { + addStat({ + name: statName, + dataType: "topValues", + logNames: (logName instanceof Array ? logName : [logName]), + init_f: "_initLatencies", + update_f: "_updateLatencies", + snapshot_f: "_snapshotLatencies", + options: { filter: filter, fieldName: fieldName, latencyFieldName: latencyFieldName }, + historicalDays: historicalDays || 1 + }); +} + + +// RETURNING USERS + +function _initReturningUsers(statName, options, timescaleType) { + return { cache: {}, uniques: _initUniques(statName, options, timescaleType) }; +} + +function _returningUsersUserId(logObject) { + if (logObject.type == "userjoin") { + return logObject.userId; + } +} + +function _returningUsersUserCreationDate(userId) { + var record = sqlobj.selectSingle('pad_cookie_userids', {id: userId}); + if (record) { + return record.createdDate.getTime(); + } +} + +function _returningUsersAccountId(logObject) { + return logObject.proAccountId; +} + +function _returningUsersAccountCreationDate(accountId) { + var record = sqlobj.selectSingle('pro_accounts', {id: accountId}); + if (record) { + return record.createdDate.getTime(); + } +} + + +function _updateReturningUsers(statName, options, logName, data, logObject) { + var userId = (options.useProAccountId ? _returningUsersAccountId(logObject) : _returningUsersUserId(logObject)); + if (! userId) { return; } + var date = logObject.date; + if (! data.cache[""+userId]) { + var creationTime = (options.useProAccountId ? _returningUsersAccountCreationDate(userId) : _returningUsersUserCreationDate(userId)); + if (! creationTime) { return; } // hm. weird case. + data.cache[""+userId] = creationTime; + } + if (data.cache[""+userId] < date - options.registeredNDaysAgo*24*60*60*1000) { + data.uniques.hit(logObject.date, ""+userId); + } +} +function _snapshotReturningUsers(statName, options, data) { + return _lazySnapshot(data.uniques); +} + +function addReturningUserStat(statName, pastNDays, registeredNDaysAgo) { + addStat({ + name: statName, + dataType: "line", + logNames: ["frontend/padevents"], + init_f: "_initReturningUsers", + update_f: "_updateReturningUsers", + snapshot_f: "_snapshotReturningUsers", + options: { registeredNDaysAgo: registeredNDaysAgo }, + historicalDays: pastNDays + }); +} + +function addReturningProAccountStat(statName, pastNDays, registeredNDaysAgo) { + addStat({ + name: statName, + dataType: "line", + logNames: ["frontend/request"], + init_f: "_initReturningUsers", + update_f: "_updateReturningUsers", + snapshot_f: "_snapshotReturningUsers", + options: { registeredNDaysAgo: registeredNDaysAgo, useProAccountId: true }, + historicalDays: pastNDays + }); +} + + +function addDisconnectStat() { + addStat({ + name: "streaming_disconnects", + dataType: "topValues", + logNames: ["frontend/padevents", "frontend/reconnect", "frontend/disconnected_autopost"], + init_f: "_initDisconnectTracker", + update_f: "_updateDisconnectTracker", + snapshot_f: "_snapshotDisconnectTracker", + options: {} + }); +} + +// PAD STARTUP LATENCY +function _initPadStartupLatency(statName, options, timescaleType) { + return { + recentGets: (timescaleType == LIVE ? new ExpiringMapping(60*1000) : _fakeMap()), + latencies: _initHistogram(statName, options, timescaleType), + } +} + +function _updatePadStartupLatency(statName, options, logName, data, logObject) { + var session = logObject.session; + if (logName == "frontend/request") { + if (! ('padId' in logObject)) { return; } + var padId = logObject.padId; + if (! data.recentGets.get(session)) { + data.recentGets.put(session, {}); + } + data.recentGets.get(session)[padId] = logObject.date; + } + if (logName == "frontend/padevents") { + if (logObject.type != 'userjoin') { return; } + if (! data.recentGets.get(session)) { return; } + var padId = logObject.padId; + var getTime = data.recentGets.get(session)[padId]; + if (! getTime) { return; } + delete data.recentGets.get(session)[padId]; + var latency = logObject.date - getTime; + if (latency < 60*1000) { + // latencies longer than 60 seconds don't represent data we care about for this stat. + data.latencies.hit(logObject.date, latency); + } + } +} + +function _snapshotPadStartupLatency(statName, options, data) { + var latencies = data.latencies; + function convertHistogram(histogram_f) { + var deciles = {}; + [0, 1, 5, 10, 25, 50, 75, 90, 95, 99, 100].forEach(function(pct) { + deciles[""+pct] = histogram_f(pct); + }); + return deciles; + } + return _lazySnapshot({ + latencies: latencies, + get total() { + return convertHistogram(this.latencies.total); + }, + history: function(bucketsPerSample, numSamples) { + return this.latencies.history(bucketsPerSample, numSamples).map(convertHistogram); + }, + latest: function(bucketsPerSample) { + return convertHistogram(this.latencies.latest(bucketsPerSample)); + } + }); +} + +function addPadStartupLatencyStat() { + addStat({ + name: "pad_startup_times", + dataType: "histogram", + logNames: ["frontend/padevents", "frontend/request"], + init_f: "_initPadStartupLatency", + update_f: "_updatePadStartupLatency", + snapshot_f: "_snapshotPadStartupLatency", + options: {} + }); +} + + +function _initSampleTracker(statName, options, timescaleType) { + return { + samples: Array(1440), // 1 hour at 1 sample/minute + nextSample: 0, + numSamples: 0 + } +} + +function _updateSampleTracker(statName, options, logName, data, logObject) { + if (options.filter && ! options.filter(logObject)) { + return; + } + if (options.fieldName && ! (options.fieldName in logObject)) { + return; + } + data.samples[data.nextSample] = (options.fieldName ? logObject[fieldName] : logObject); + data.nextSample++; + data.nextSample %= data.samples.length; + data.numSamples = Math.min(data.samples.length, data.numSamples+1); +} + +function _snapshotSampleTracker(statName, options, data) { + function indexTransform(i) { + return (data.nextSample-data.numSamples+i + data.samples.length) % data.samples.length; + } + var merge_f = options.mergeFunction || function(a, b) { return a+b; } + var process_f = options.processFunction || function(a) { return a; } + function mergeValues(values) { + if (values.length <= 1) { return values[0]; } + var t = values[0]; + for (var i = 1; i < values.length; ++i) { + t = merge_f(values[i], t); + } + return t; + } + return _lazySnapshot({ + get total() { + var t = []; + for (var i = 0; i < data.numSamples; ++i) { + t.push(data.samples[indexTransform(i)]); + } + return process_f(mergeValues(t), t.length); + }, + history: function(bucketsPerSample, numSamples) { + var allSamples = []; + for (var i = data.numSamples-1; i >= Math.max(0, data.numSamples - bucketsPerSample*numSamples); --i) { + allSamples.push(data.samples[indexTransform(i)]); + } + var out = []; + for (var i = 0; i < numSamples && i*bucketsPerSample < allSamples.length; ++i) { + var subArray = []; + for (var j = 0; j < bucketsPerSample && i*bucketsPerSample+j < allSamples.length; ++j) { + subArray.push(allSamples[i*bucketsPerSample+j]); + } + out.push(process_f(mergeValues(subArray), subArray.length)); + } + return out.reverse(); + }, + latest: function(bucketsPerSample) { + var t = []; + for (var i = data.numSamples-1; i >= Math.max(0, data.numSamples-bucketsPerSample); --i) { + t.push(data.samples[indexTransform(i)]); + } + return process_f(mergeValues(t), t.length); + } + }); +} + +function addSampleTracker(statName, logName, filter, fieldName, mergeFunction, processFunction) { + addStat({ + name: statName, + dataType: "histogram", + logNames: (logName instanceof Array ? logName : [logName]), + init_f: "_initSampleTracker", + update_f: "_updateSampleTracker", + snapshot_f: "_snapshotSampleTracker", + options: { filter: filter, fieldName: fieldName, + mergeFunction: mergeFunction, processFunction: processFunction } + }); +} + +function addCometLatencySampleTracker(statName) { + addSampleTracker(statName, "backend/server-events", typeMatcher("streaming-message-latencies"), null, + function(a, b) { + var ret = {}; + ["count", "p50", "p90", "p95", "p99", "max"].forEach(function(key) { + ret[key] = (Number(a[key]) || 0) + (Number(b[key]) || 0); + }); + return ret; + }, + function(v, count) { + if (count == 0) { + return { + "50": 0, "90": 0, "95": 0, "99": 0, "100": 0 + } + } + var ret = {count: v.count}; + ["p50", "p90", "p95", "p99", "max"].forEach(function(key) { + ret[key] = (Number(v[key]) || 0)/(Number(count) || 1); + }); + return {"50": Math.round(ret.p50/1000), + "90": Math.round(ret.p90/1000), + "95": Math.round(ret.p95/1000), + "99": Math.round(ret.p99/1000), + "100": Math.round(ret.max/1000)}; + }); +} + +function addConnectionTypeSampleTracker(statName) { + var caredAboutFields = ["streaming", "longpolling", "shortpolling", "(unconnected)"]; + + addSampleTracker(statName, "backend/server-events", typeMatcher("streaming-connection-count"), null, + function(a, b) { + var ret = {}; + caredAboutFields.forEach(function(k) { + ret[k] = (Number(a[k]) || 0) + (Number(b[k]) || 0); + }); + return ret; + }, + function(v, count) { + if (count == 0) { + return _convertTopValue({total: 0, counts: {}}); + } + var values = {}; + var total = 0; + caredAboutFields.forEach(function(k) { + values[k] = Math.round((Number(v[k]) || 0)/count); + total += values[k]; + }); + values["Total"] = total; + return _convertTopValue({ + total: Math.round(total), + counts: values + }); + }); +} + +// helpers for filter functions + +function expectedHostnames() { + var hostPart = appjet.config.listenHost || "localhost"; + if (appjet.config.listenSecureHost != hostPart) { + hostPart = "("+hostPart+"|"+(appjet.config.listenSecureHost || "localhost")+")"; + } + var ports = []; + if (appjet.config.listenPort != 80) { + ports.push(""+appjet.config.listenPort); + } + if (appjet.config.listenSecurePort != 443) { + ports.push(""+appjet.config.listenSecurePort); + } + var portPart = (ports.length > 0 ? ":("+ports.join("|")+")" : ""); + return hostPart + portPart; +} + +function fieldMatcher(fieldName, fieldValue) { + if (fieldValue instanceof RegExp) { + return function(logObject) { + return fieldValue.test(logObject[fieldName]); + } + } else { + return function(logObject) { + return logObject[fieldName] == fieldValue; + } + } +} + +function typeMatcher(type) { + return fieldMatcher("type", type); +} + +function invertMatcher(f) { + return function(logObject) { + return ! f(logObject); + } +} + +function setupStatsCollector() { + var c; + + function unwatchLog(logName) { + GenericLoggerUtils.clearWrangler(logName.split('/')[1], c.wranglers[logName]); + } + function watchLog(logName) { + c.wranglers[logName] = new Packages.net.appjet.oui.LogWrangler({ + tell: function(lpb) { + c.queue.add({logName: logName, json: lpb.json()}); + } + }); + c.wranglers[logName].watch(logName.split('/')[1]); + } + + c = _stats().liveCollector; + if (c) { + c.watchedLogs.forEach(unwatchLog); + delete c.wrangler; + } else { + c = _stats().liveCollector = {}; + } + c.watchedLogs = keys(_stats().logNamesToInterestedStatNames); + c.queue = new java.util.concurrent.ConcurrentLinkedQueue(); + c.wranglers = {}; + c.watchedLogs.forEach(watchLog); + + if (! c.updateTask || c.updateTask.isDone()) { + c.updateTask = execution.scheduleTask('statistics', "statisticsLiveUpdate", 2000, []); + } +} + +serverhandlers.tasks.statisticsLiveUpdate = function() { + var c = _stats().liveCollector; + try { + while (true) { + var obj = c.queue.poll(); + if (obj != null) { + var statNames = + keys(_stats().logNamesToInterestedStatNames[obj.logName]); + var logObject = fastJSON.parse(obj.json); + statNames.forEach(function(statName) { + var statObject = getStatData(statName); + _callFunction(statObject.update_f, + statName, statObject.options, obj.logName, statObject.data, logObject); + }); + } else { + break; + } + } + } catch (e) { + println("EXCEPTION IN LIVE UPDATE: "+e+" / "+e.fileName+":"+e.lineNumber) + println(exceptionutils.getStackTracePlain(new net.appjet.bodylock.JSRuntimeException(String(e), e.javaException || e.rhinoException))); + } finally { + c.updateTask = execution.scheduleTask('statistics', "statisticsLiveUpdate", 2000, []); + } +} + +function onReset() { + // this gets refilled every reset. + _stats().logNamesToInterestedStatNames = {}; + + // we'll want to keep around the live data, though, so this is conditionally set. + if (! _stats().stats) { + _stats().stats = {}; + } + + addSimpleCount("site_pageviews", 1, "frontend/request", null); + addUniquenessCount("site_unique_ips", 1, "frontend/request", null, "clientAddr"); + + addUniquenessCount("active_user_ids", 1, "frontend/padevents", typeMatcher("userjoin"), "userId"); + addUniquenessCount("active_user_ids_7days", 7, "frontend/padevents", typeMatcher("userjoin"), "userId"); + addUniquenessCount("active_user_ids_30days", 30, "frontend/padevents", typeMatcher("userjoin"), "userId"); + + addUniquenessCount("active_pro_accounts", 1, "frontend/request", invertMatcher(fieldMatcher("proAccountId", undefined)), + "proAccountId"); + addUniquenessCount("active_pro_accounts_7days", 7, "frontend/request", invertMatcher(fieldMatcher("proAccountId", undefined)), + "proAccountId"); + addUniquenessCount("active_pro_accounts_30days", 30, "frontend/request", invertMatcher(fieldMatcher("proAccountId", undefined)), + "proAccountId"); + + + addUniquenessCount("active_pads", 1, "frontend/padevents", typeMatcher("userjoin"), "padId"); + addSimpleCount("new_pads", 1, "frontend/padevents", typeMatcher("newpad")); + + addSimpleCount("chat_messages", 1, "frontend/chat", null); + addUniquenessCount("active_chatters", 1, "frontend/chat", null, "userId"); + + addSimpleCount("exceptions", 1, "frontend/exception", null); + + addSimpleCount("eepnet_trial_downloads", 1, "frontend/eepnet_download_info", null); + + addSimpleSum("revenue", 1, "frontend/billing", typeMatcher("purchase-complete"), "dollars") + + var hostRegExp = new RegExp("^https?:\\/\\/([-a-zA-Z0-9]+.)?"+expectedHostnames()+"\\/"); + addTopValuesStat("top_referers", 1, "frontend/request", + invertMatcher(fieldMatcher( + "referer", hostRegExp)), + "referer"); + + addTopValuesStat("paths_404", 1, "frontend/request", fieldMatcher("statusCode", 404), "path"); + addTopValuesStat("paths_500", 1, "frontend/request", fieldMatcher("statusCode", 500), "path"); + addTopValuesStat("paths_exception", 1, "frontend/exception", null, "path"); + + addTopValuesStat("top_exceptions", 1, ["frontend/exception", "backend/exceptions"], + invertMatcher(fieldMatcher("trace", undefined)), + "trace", function(trace) { + var jstrace = trace.split("\n").filter(function(line) { + return /^\tat JS\$.*?\.js:\d+\)$/.test(line); + }); + if (jstrace.length > 3) { + return "JS Exception:\n"+jstrace.slice(0, 10).join("\n").replace(/\t[^\(]*/g, ""); + } + return trace.split("\n").slice(1, 10).join("\n").replace(/\t/g, ""); + }); + + addReturningUserStat("users_1day_returning_7days", 1, 7); + addReturningUserStat("users_7day_returning_7days", 7, 7); + addReturningUserStat("users_30day_returning_7days", 30, 7); + + addReturningUserStat("users_1day_returning_30days", 1, 30); + addReturningUserStat("users_7day_returning_30days", 7, 30); + addReturningUserStat("users_30day_returning_30days", 30, 30); + + addReturningProAccountStat("pro_accounts_1day_returning_7days", 1, 7); + addReturningProAccountStat("pro_accounts_7day_returning_7days", 7, 7); + addReturningProAccountStat("pro_accounts_30day_returning_7days", 30, 7); + + addReturningProAccountStat("pro_accounts_1day_returning_30days", 1, 30); + addReturningProAccountStat("pro_accounts_7day_returning_30days", 7, 30); + addReturningProAccountStat("pro_accounts_30day_returning_30days", 30, 30); + + + addDisconnectStat(); + addTopValuesStat("disconnect_causes", 1, "frontend/avoidable_disconnects", null, "errorMessage"); + + var staticFileRegExp = /^\/static\/|^\/favicon.ico/; + addLatenciesStat("execution_latencies", 1, "backend/latency", + invertMatcher(fieldMatcher('path', staticFileRegExp)), + "path", "time"); + addLatenciesStat("static_file_latencies", 1, "backend/latency", + fieldMatcher('path', staticFileRegExp), + "path", "time"); + + addUniquenessCount("disconnects_with_clientside_errors", 1, + ["frontend/reconnect", "frontend/disconnected_autopost"], + fieldMatcher("hasClientErrors", true), "uniqueId"); + + addTopValuesStat("imports_exports_counts", 1, "frontend/import-export", + typeMatcher("request"), "direction"); + + addPadStartupLatencyStat(); + + addCometLatencySampleTracker("streaming_latencies"); + addConnectionTypeSampleTracker("streaming_connections"); + // TODO: add more stats here. + + setupStatsCollector(); +} + +//---------------------------------------------------------------- +// Log processing +//---------------------------------------------------------------- + +function _whichStats(statNames) { + var whichStats = _statData(); + var logNamesToInterestedStatNames = _stats().logNamesToInterestedStatNames; + + if (statNames) { + whichStats = {}; + statNames.forEach(function(statName) { whichStats[statName] = getStatData(statName) }); + logNamesToInterestedStatNames = _generateLogInterestMap(statNames); + } + + return [whichStats, logNamesToInterestedStatNames]; +} + +function _initStatDataMap(statNames) { + var [whichStats, logNamesToInterestedStatNames] = _whichStats(statNames); + + var statDataMap = {}; + + function initStat(statName, statObject) { + statDataMap[statName] = + _callFunction(statObject.init_f, statName, statObject.options, HIST); + } + eachProperty(whichStats, initStat); + + return statDataMap; +} + +function _saveStats(day, statDataMap, statNames) { + var [whichStats, logNamesToInterestedStatNames] = _whichStats(statNames); + + function saveStat(statName, statObject) { + var value = _callFunction(statObject.snapshot_f, + statName, statObject.options, statDataMap[statName]).total; + if (typeof(value) != 'object') { + value = {value: value}; + } + _saveStat(day, statName, value); + } + eachProperty(whichStats, saveStat); +} + +function _processSingleDayLogs(day, logNamesToInterestedStatNames, statDataMap) { + var iterators = {}; + keys(logNamesToInterestedStatNames).forEach(function(logName) { + var [prefix, logId] = logName.split("/"); + var fileName = log.logFileName(prefix, logId, day); + if (! fileName) { + _info("No such file: "+logName+" on day "+day); + return; + } + iterators[logName] = fileLineIterator(fileName); + }); + + var numIterators = keys(iterators).length; + if (numIterators == 0) { + _info("No logs to process on day "+day); + return; + } + var sortedLogObjects = new java.util.PriorityQueue(numIterators, + new java.util.Comparator({ + compare: function(o1, o2) { return o1.logObject.date - o2.logObject.date } + })); + + function lineToLogObject(logName, json) { + return {logName: logName, logObject: fastJSON.parse(json)}; + } + + // begin by filling the queue with one object from each log. + eachProperty(iterators, function(logName, iterator) { + if (iterator.hasNext) { + sortedLogObjects.add(lineToLogObject(logName, iterator.next)); + } + }); + + // update with all log objects, in date order (enforced by priority queue). + while (! sortedLogObjects.isEmpty()) { + var nextObject = sortedLogObjects.poll(); + var logName = nextObject.logName; + + keys(logNamesToInterestedStatNames[logName]).forEach(function(statName) { + var statObject = getStatData(statName); + _callFunction(statObject.update_f, + statName, statObject.options, logName, statDataMap[statName], nextObject.logObject); + }); + + // get next entry from this log, if there is one. + if (iterators[logName].hasNext) { + sortedLogObjects.add(lineToLogObject(logName, iterators[logName].next)); + } + } +} + +function processStatsForDay(day, statNames, statDataMap) { + var [whichStats, logNamesToInterestedStatNames] = _whichStats(statNames); + + // process the logs, notifying the right statistics updaters. + _processSingleDayLogs(day, logNamesToInterestedStatNames, statDataMap); +} + +//---------------------------------------------------------------- +// Daily update +//---------------------------------------------------------------- +serverhandlers.tasks.statisticsDailyUpdate = function() { +// do nothing for now. + +// dailyUpdate(); +}; + +function _scheduleNextDailyUpdate() { + // Run at 1:11am every day + var now = +(new Date); + var tomorrow = new Date(now + 1000*60*60*24); + tomorrow.setHours(1); + tomorrow.setMinutes(11); + tomorrow.setMilliseconds(111); + log.info("Scheduling next daily statistics update for: "+tomorrow.toString()); + var delay = +tomorrow - (+(new Date)); + execution.scheduleTask("statistics", "statisticsDailyUpdate", delay, []); +} + +function processStatsAsOfDay(date, statNames) { + var latestDay = noon(new Date(date - 1000*60*60*24)); + + _processLogsForNeededDays(latestDay, statNames); +} + +function _processLogsForNeededDays(latestDay, statNames) { + if (! statNames) { + statNames = getAllStatNames(); + } + var statDataMap = _initStatDataMap(statNames); + + var agesToStats = []; + var atLeastOneStat = true; + for (var i = 0; atLeastOneStat; ++i) { + atLeastOneStat = false; + agesToStats[i] = []; + statNames.forEach(function(statName) { + var statData = getStatData(statName); + if (statData.historicalDays > i) { + atLeastOneStat = true; + agesToStats[i].push(statName); + } + }); + } + agesToStats.pop(); + + for (var i = agesToStats.length-1; i >= 0; --i) { + var day = new Date(+latestDay - i*24*60*60*1000); + processStatsForDay(day, agesToStats[i], statDataMap); + } + _saveStats(latestDay, statDataMap, statNames); +} + +function doDailyUpdate(date) { + var now = (date === undefined ? new Date() : date); + var yesterdayNoon = noon(new Date(+now - 1000*60*60*24)); + + _processLogsForNeededDays(yesterdayNoon); +} + +function dailyUpdate() { + try { + doDailyUpdate(); + } catch (ex) { + log.warn("statistics.dailyUpdate() failed: "+ex.toString()); + } finally { + _scheduleNextDailyUpdate(); + } +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/store/checkout.js b/trunk/etherpad/src/etherpad/store/checkout.js new file mode 100644 index 0000000..2a4d7e7 --- /dev/null +++ b/trunk/etherpad/src/etherpad/store/checkout.js @@ -0,0 +1,300 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("dateutils"); +import("email.sendEmail"); +import("jsutils.*"); +import("sqlbase.sqlobj"); +import("stringutils"); +import("sync"); + +import("etherpad.globals"); +import("etherpad.globals.*"); +import("etherpad.licensing"); +import("etherpad.utils.*"); + +import("static.js.billing_shared.{billing=>billingJS}"); + +function dollars(x, nocommas) { + if (! x) { return "0.00"; } + var s = String(x); + var dollars = s.split('.')[0]; + var pennies = s.split('.')[1]; + + if (!dollars) { + dollars = "0"; + } + + if (!nocommas && dollars.length > 3) { + var newDollars = []; + newDollars.push(dollars[dollars.length-1]); + + for (var i = 1; i < dollars.length; ++i) { + if (i % 3 == 0) { + newDollars.push(","); + } + newDollars.push(dollars[dollars.length-1-i]); + } + dollars = newDollars.reverse().join(''); + } + + if (!pennies) { + pennies = "00"; + } + + if (pennies.length == 1) { + pennies = pennies + "0"; + } + + if (pennies.length > 2) { + pennies = pennies.substr(0,2); + } + + return [dollars,pennies].join('.'); +} + +function obfuscateCC(x) { + if (x.length == 16 || x.length == 15) { + return stringutils.repeat("X", x.length-4) + x.substr(-4); + } else { + return x; + } +} + + +// validation functions + +function isOnlyDigits(s) { + return /^[0-9]+$/.test(s); +} + +function isOnlyLettersAndSpaces(s) { + return /^[a-zA-Z ]+$/.test(s); +} + +function isLength(s, minLen, maxLen) { + if (maxLen === undefined) { + return (typeof(s) == 'string' && s.length == minLen); + } else { + return (typeof(s) == 'string' && s.length >= minLen && s.length <= maxLen); + } +} + +function errorMissing(validationError, name, description) { + validationError(name, "Please enter a "+description+"."); +} + +function errorTooSomething(validationError, name, description, max, tooWhat, betterAdjective) { + validationError(name, "Your "+description+" is too " + tooWhat + "; please provide a "+description+ + " that is "+max+" characters or "+betterAdjective); +} + +function validateString(validationError, s, name, description, mustExist, maxLength, minLength) { + if (mustExist && ! s) { + errorMissing(validationError, name, description); + } + if (s && s.length > maxLength) { + errorTooSomething(validationError, name, description, maxLength, "long", "shorter"); + } + if (minLength > 0 && s.length < minLength) { + errorTooSomething(validationError, name, description, minLength, "short", "longer"); + } +} + +function validateZip(validationError, s) { + if (! s) { + errorMissing(validationError, 'billingZipCode', "ZIP code"); + } + if (! (/^\d{5}(-\d{4})?$/.test(s))) { + validationError('billingZipCode', "Please enter a valid ZIP code"); + } +} + +function validateBillingCart(validationError, cart) { + var p = cart; + + if (! isOnlyLettersAndSpaces(p.billingFirstName)) { + validationError("billingFirstName", "Name fields may only contain alphanumeric characters."); + } + + if (! isOnlyLettersAndSpaces(p.billingLastName)) { + validationError("billingLastName", "Name fields may only contain alphanumeric characters."); + } + + var validPurchaseTypes = arrayToSet(['creditcard', 'invoice', 'paypal']); + if (! p.billingPurchaseType in validPurchaseTypes) { + validationError("billingPurchaseType", "Please select a valid purchase type.") + } + + switch (p.billingPurchaseType) { + case 'creditcard': + if (! billingJS.validateCcNumber(p.billingCCNumber)) { + validationError("billingCCNumber", "Your card number doesn't appear to be valid."); + } + if (! isOnlyDigits(p.billingExpirationMonth) || + ! isLength(p.billingExpirationMonth, 1, 2)) { + validationError("billingMeta", "Invalid expiration month."); + } + if (! isOnlyDigits(p.billingExpirationYear) || + ! isLength(p.billingExpirationYear, 1, 2)) { + validationError("billingMeta", "Invalid expiration year."); + } + if (Number("20"+p.billingExpirationYear) <= (new Date()).getFullYear() && + Number(p.billingExpirationMonth) < (new Date()).getMonth()+1) { + validationError("billingMeta", "Invalid expiration date."); + } + var ccType = billingJS.getCcType(p.billingCCNumber); + if (! isOnlyDigits(p.billingCSC) || + ! isLength(p.billingCSC, (ccType == 'amex' ? 4 : 3))) { + validationError("billingMeta", "Invalid CSC."); + } + // falling through here! + case 'invoice': + validateString(validationError, p.billingCountry, "billingCountry", "country name", true, 2); + validateString(validationError, p.billingAddressLine1, "billingAddressLine1", "billing address", true, 100); + validateString(validationError, p.billingAddressLine2, "billingAddressLine2", "billing address", false, 100); + validateString(validationError, p.billingCity, "billingCity", "city name", true, 40); + if (p.billingCountry == "US") { + validateString(validationError, p.billingState, "billingState", "state name", true, 2); + validateZip(validationError, p.billingZipCode); + } else { + validateString(validationError, p.billingProvince, "billingProvince", "province name", true, 40, 1); + validateString(validationError, p.billingPostalCode, "billingPostalCode", "postal code", true, 20, 5); + } + } +} + +function _cardType(number) { + var cardType = billingJS.getCcType(number); + switch (cardType) { + case 'visa': + return "Visa"; + case 'amex': + return "Amex"; + case 'disc': + return "Discover"; + case 'mc': + return "MasterCard"; + } +} + +function generatePayInfo(cart) { + var isUs = cart.billingCountry == "US"; + + var payInfo = { + cardType: _cardType(cart.billingCCNumber), + cardNumber: cart.billingCCNumber, + cardExpiration: ""+cart.billingExpirationMonth+"20"+cart.billingExpirationYear, + cardCvv: cart.billingCSC, + + nameSalutation: "", + nameFirst: cart.billingFirstName, + nameMiddle: "", + nameLast: cart.billingLastName, + nameSuffix: "", + + addressStreet: cart.billingAddressLine1, + addressStreet2: cart.billingAddressLine2, + addressCity: cart.billingCity, + addressState: (isUs ? cart.billingState : cart.billingProvince), + addressZip: (isUs ? cart.billingZipCode : cart.billingPostalCode), + addressCountry: cart.billingCountry + } + + return payInfo; +} + +var billingCartFieldMap = { + cardType: {f: ["billingCCNumber"], d: "credit card number"}, + cardNumber: { f: ["billingCCNumber"], d: "credit card number"}, + cardExpiration: { f: ["billingMeta", "billingMeta"], d: "expiration date" }, + cardCvv: { f: ["billingMeta"], d: "card security code" }, + card: { f: ["billingCCNumber", "billingMeta"], d: "credit card"}, + nameFirst: { f: ["billingFirstName"], d: "first name" }, + nameLast: {f: ["billingLastName"], d: "last name" }, + addressStreet: { f: ["billingAddressLine1"], d: "billing address" }, + addressStreet2: { f: ["billingAddressLine2"], d: "billing address" }, + addressCity: { f: ["billingCity"], d: "city" }, + addressState: { f: ["billingState", "billingProvince"], d: "state or province" }, + addressCountry: { f: ["billingCountry"], d: "country" }, + addressZip: { f: ["billingZipCode", "billingPostalCode"], d: "ZIP or postal code" }, + address: { f: ["billingAddressLine1", "billingAddressLine2", "billingCity", "billingState", "billingCountry", "billingZipCode"], d: "address" } +} + +function validateErrorFields(validationError, errorPrefix, fieldList) { + if (fieldList.length > 0) { + var errorMsg; + var errorFields; + errorMsg = errorPrefix + + fieldList.map(function(field) { return billingCartFieldMap[field].d }).join(", ") + + "."; + errorFields = []; + fieldList.forEach(function(field) { + errorFields = errorFields.concat(billingCartFieldMap[field].f); + }); + validationError(errorFields, errorMsg); + } +} + +function guessBillingNames(cart, name) { + if (! cart.billingFirstName && ! cart.billingLastName) { + var nameParts = name.split(/\s+/); + if (nameParts.length == 1) { + cart.billingFirstName = nameParts[0]; + } else { + cart.billingLastName = nameParts[nameParts.length-1]; + cart.billingFirstName = nameParts.slice(0, nameParts.length-1).join(' '); + } + } +} + +function writeToEncryptedLog(s) { + if (! appjet.config["etherpad.billingEncryptedLog"]) { + // no need to log, this probably isn't the live server. + return; + } + var e = net.appjet.oui.Encryptomatic; + sync.callsyncIfTrue(appjet.cache, + function() { return ! appjet.cache.billingEncryptedLog }, + function() { + appjet.cache.billingEncryptedLog = { + writer: new java.io.FileWriter(appjet.config["etherpad.billingEncryptedLog"], true), + key: e.readPublicKey("RSA", new java.io.FileInputStream(appjet.config["etherpad.billingPublicKey"])) + } + }); + var l = appjet.cache.billingEncryptedLog; + sync.callsync(l, function() { + l.writer.write(e.bytesToAscii(e.encrypt( + new java.io.ByteArrayInputStream((new java.lang.String(s)).getBytes("UTF-8")), + l.key))+"\n"); + l.writer.flush(); + }) +} + +function formatExpiration(expiration) { + return dateutils.shortMonths[Number(expiration.substr(0, 2))-1]+" "+expiration.substr(2); +} + +function formatDate(date) { + return dateutils.months[date.getMonth()]+" "+date.getDate()+", "+date.getFullYear(); +} + +function salesEmail(to, from, subject, headers, body) { + sendEmail(to, from, subject, headers, body); + if (globals.isProduction()) { + sendEmail("sales@pad.spline.inf.fu-berlin.de", from, subject, headers, body); + } +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/store/eepnet_checkout.js b/trunk/etherpad/src/etherpad/store/eepnet_checkout.js new file mode 100644 index 0000000..62137d3 --- /dev/null +++ b/trunk/etherpad/src/etherpad/store/eepnet_checkout.js @@ -0,0 +1,101 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("email.sendEmail"); +import("sqlbase.sqlobj"); +import("stringutils"); + +import("etherpad.globals"); +import("etherpad.globals.*"); +import("etherpad.licensing"); +import("etherpad.utils.*"); +import("etherpad.store.checkout.*"); + +var COST_PER_USER = 99; +var SUPPORT_COST_PCT = 20; +var SUPPORT_MIN_COST = 50; + +function getPurchaseByEmail(email) { + return sqlobj.selectSingle('checkout_purchase', {email: email}); +} + +function hasEmailAlreadyPurchased(email) { + var purchase = getPurchaseByEmail(email); + return purchase && purchase.licenseKey ? true : false; +} + +function mailLostLicense(email) { + var purchase = getPurchaseByEmail(email); + if (purchase && purchase.licenseKey) { + sendLicenseEmail({ + email: email, + ownerName: purchase.owner, + orgName: purchase.organization, + licenseKey: purchase.licenseKey + }); + } +} + +function _updatePurchaseWithKey(id, key) { + sqlobj.updateSingle('checkout_purchase', {id: id}, {licenseKey: key}); +} + +function updatePurchaseWithReceipt(id, text) { + sqlobj.updateSingle('checkout_purchase', {id: id}, {receiptEmail: text}); +} + +function getPurchaseByInvoiceId(id) { + sqlobj.selectSingle('checkout_purchase', {invoiceId: id}); +} + +function generateLicenseKey(cart) { + var licenseKey = licensing.generateNewKey(cart.ownerName, cart.orgName, null, 2, cart.userCount); + cart.licenseKey = licenseKey; + _updatePurchaseWithKey(cart.customerId, cart.licenseKey); + return licenseKey; +} + +function receiptEmailText(cart) { + return renderTemplateAsString('email/eepnet_purchase_receipt.ejs', { + cart: cart, + dollars: dollars, + obfuscateCC: obfuscateCC + }); +} + +function licenseEmailText(userName, licenseKey) { + return renderTemplateAsString('email/eepnet_license_info.ejs', { + userName: userName, + licenseKey: licenseKey, + isEvaluation: false + }); +} + +function sendReceiptEmail(cart) { + var receipt = cart.receiptEmail || receiptEmailText(cart); + + salesEmail(cart.email, "sales@pad.spline.inf.fu-berlin.de", + "EtherPad: Receipt for "+cart.ownerName+" ("+cart.orgName+")", + {}, receipt); +} + +function sendLicenseEmail(cart) { + var licenseEmail = licenseEmailText(cart.ownerName, cart.licenseKey); + + salesEmail(cart.email, "sales@pad.spline.inf.fu-berlin.de", + "EtherPad: License Key for "+cart.ownerName+" ("+cart.orgName+")", + {}, licenseEmail); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/store/eepnet_trial.js b/trunk/etherpad/src/etherpad/store/eepnet_trial.js new file mode 100644 index 0000000..570d351 --- /dev/null +++ b/trunk/etherpad/src/etherpad/store/eepnet_trial.js @@ -0,0 +1,241 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("email.sendEmail"); +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon"); +import("execution"); + +import("etherpad.sessions.getSession"); +import("etherpad.log"); +import("etherpad.licensing"); +import("etherpad.utils.*"); +import("etherpad.globals.*"); + +//---------------------------------------------------------------- + +function getTrialDays() { + return 30; +} + +function getTrialUserQuota() { + return 100; +} + +function mailLicense(data, licenseKey, expiresDate) { + var toAddr = data.email; + if (isTestEmail(toAddr)) { + toAddr = "blackhole@appjet.com"; + } + var subject = ('EtherPad: Trial License Information for '+ + data.firstName+' '+data.lastName+' ('+data.orgName+')'); + + var emailBody = renderTemplateAsString("email/eepnet_license_info.ejs", { + userName: data.firstName+" "+data.lastName, + licenseKey: licenseKey, + expiresDate: expiresDate, + isEvaluation: true + }); + + sendEmail( + toAddr, + 'sales@pad.spline.inf.fu-berlin.de', + subject, + {}, + emailBody + ); +} + +function mailLostLicense(email) { + var data = sqlobj.selectSingle('eepnet_signups', {email: email}); + var keyInfo = licensing.decodeLicenseInfoFromKey(data.licenseKey); + var expiresDate = keyInfo.expiresDate; + + mailLicense(data, data.licenseKey, expiresDate); +} + +function hasEmailAlreadyDownloaded(email) { + var existingRecord = sqlobj.selectSingle('eepnet_signups', {email: email}); + if (existingRecord) { + return true; + } else { + return false + } +} + +function createAndMailNewLicense(data) { + sqlcommon.inTransaction(function() { + var expiresDate = new Date(+(new Date)+(1000*60*60*24*getTrialDays())); + var licenseKey = licensing.generateNewKey( + data.firstName + ' ' + data.lastName, + data.orgName, + +expiresDate, + licensing.getEditionId('PRIVATE_NETWORK_EVALUATION'), + getTrialUserQuota() + ); + + // confirm key + if (!licensing.isValidKey(licenseKey)) { + throw Error("License key I just created is not valid: "+l); + } + + // Log all this precious info + _logDownloadData(data, licenseKey); + + // Store in database + sqlobj.insert("eepnet_signups", { + firstName: data.firstName, + lastName: data.lastName, + email: data.email, + orgName: data.orgName, + jobTitle: data.jobTitle, + date: new Date(), + signupIp: String(request.clientAddr).substr(0,16), + estUsers: data.estUsers, + licenseKey: licenseKey, + phone: data.phone, + industry: data.industry + }); + + mailLicense(data, licenseKey, expiresDate); + + // Send sales notification + var clientAddr = request.clientAddr; + var initialReferer = getSession().initialReferer; + execution.async(function() { + _sendSalesNotification(data, clientAddr, initialReferer); + }); + + }); // end transaction +} + +function _logDownloadData(data, licenseKey) { + log.custom("eepnet_download_info", { + email: data.email, + firstName: data.firstName, + lastName: data.lastName, + org: data.orgName, + jobTitle: data.jobTitle, + phone: data.phone, + estUsers: data.estUsers, + licenseKey: licenseKey, + ip: request.clientAddr, + industry: data.industry, + referer: getSession().initialReferer + }); +} + +function getWeb2LeadData(data, ip, ref) { + var googleQuery = extractGoogleQuery(ref); + var w2ldata = { + oid: "00D80000000b7ey", + first_name: data.firstName, + last_name: data.lastName, + email: data.email, + company: data.orgName, + title: data.jobTitle, + phone: data.phone, + '00N80000003FYtG': data.estUsers, + '00N80000003FYto': ref, + '00N80000003FYuI': googleQuery, + lead_source: 'EEPNET Download', + industry: data.industry + }; + + if (!isProduction()) { +// w2ldata.debug = "1"; +// w2ldata.debugEmail = "aaron@appjet.com"; + } + + return w2ldata; +} + +function _sendSalesNotification(data, ip, ref) { + var hostname = ipToHostname(ip) || "unknown"; + + var subject = "EEPNET Trial Download: "+[data.orgName, data.firstName + ' ' + data.lastName, data.email].join(" / "); + + var body = [ + "", + "This is an automated message.", + "", + "Somebody downloaded a "+getTrialDays()+"-day trial of EEPNET.", + "", + "This lead should be automatically added to the AppJet salesforce account.", + "", + "Organization: "+data.orgName, + "Industry: "+data.industry, + "Full Name: "+data.firstName + ' ' + data.lastName, + "Job Title: "+data.jobTitle, + "Email: "+data.email, + 'Phone: '+data.phone, + "Est. Users: "+data.estUsers, + "IP Address: "+ip+" ("+hostname+")", + "Session Referer: "+ref, + "" + ].join("\n"); + + var toAddr = 'sales@pad.spline.inf.fu-berlin.de'; + if (isTestEmail(data.email)) { + toAddr = 'blackhole@appjet.com'; + } + sendEmail( + toAddr, + 'sales@pad.spline.inf.fu-berlin.de', + subject, + {'Reply-To': data.email}, + body + ); +} + +function getSalesforceIndustryList() { + return [ + '--None--', + 'Agriculture', + 'Apparel', + 'Banking', + 'Biotechnology', + 'Chemicals', + 'Communications', + 'Construction', + 'Consulting', + 'Education', + 'Electronics', + 'Energy', + 'Engineering', + 'Entertainment', + 'Environmental', + 'Finance', + 'Food & Beverage', + 'Government', + 'Healthcare', + 'Hospitality', + 'Insurance', + 'Machinery', + 'Manufacturing', + 'Media', + 'Not For Profit', + 'Other', + 'Recreation', + 'Retail', + 'Shipping', + 'Technology', + 'Telecommunications', + 'Transportation', + 'Utilities' + ]; +} + diff --git a/trunk/etherpad/src/etherpad/testing/testutils.js b/trunk/etherpad/src/etherpad/testing/testutils.js new file mode 100644 index 0000000..eac7840 --- /dev/null +++ b/trunk/etherpad/src/etherpad/testing/testutils.js @@ -0,0 +1,23 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +function assertTruthy(x) { + if (!x) { + throw new Error("assertTruthy failure: "+x); + } +} + + diff --git a/trunk/etherpad/src/etherpad/testing/unit_tests/t0000_test.js b/trunk/etherpad/src/etherpad/testing/unit_tests/t0000_test.js new file mode 100644 index 0000000..9e0e78b --- /dev/null +++ b/trunk/etherpad/src/etherpad/testing/unit_tests/t0000_test.js @@ -0,0 +1,22 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +function run() { + return "This is a test test."; +} + + diff --git a/trunk/etherpad/src/etherpad/testing/unit_tests/t0001_sqlbase_transaction_rollback.js b/trunk/etherpad/src/etherpad/testing/unit_tests/t0001_sqlbase_transaction_rollback.js new file mode 100644 index 0000000..96a74e4 --- /dev/null +++ b/trunk/etherpad/src/etherpad/testing/unit_tests/t0001_sqlbase_transaction_rollback.js @@ -0,0 +1,48 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.sqlcommon.{withConnection,inTransaction,closing}"); +import("sqlbase.sqlobj"); + +import("etherpad.testing.testutils.*"); + +function run() { + + withConnection(function(conn) { + var s = conn.createStatement(); + closing(s, function() { + s.execute("delete from just_a_test"); + }); + }); + + sqlobj.insert("just_a_test", {id: 1, x: "a"}); + + try { // this should fail + inTransaction(function(conn) { + sqlobj.updateSingle("just_a_test", {id: 1}, {id: 1, x: "b"}); + // note: this will be pritned to the console, but that's OK + throw Error(); + }); + } catch (e) {} + + var testRecord = sqlobj.selectSingle("just_a_test", {id: 1}); + + assertTruthy(testRecord.x == "a"); +} + + + + diff --git a/trunk/etherpad/src/etherpad/testing/unit_tests/t0002_license_generation.js b/trunk/etherpad/src/etherpad/testing/unit_tests/t0002_license_generation.js new file mode 100644 index 0000000..67c79d8 --- /dev/null +++ b/trunk/etherpad/src/etherpad/testing/unit_tests/t0002_license_generation.js @@ -0,0 +1,89 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("stringutils"); +import("sqlbase.sqlobj"); + +import("etherpad.licensing"); + +jimport("java.util.Random"); + +function run() { + var r = new Random(0); + + function testLicense(name, org, expires, editionId, userQuota) { + function keydataString() { + return "{name: "+name+", org: "+org+", expires: "+expires+", editionId: "+editionId+", userQuota: "+userQuota+"}"; + } + var key = licensing.generateNewKey(name, org, expires, editionId, userQuota); + var info = licensing.decodeLicenseInfoFromKey(key); + if (!info) { + println("Generated key does not decode at all: "+keydataString()); + println(" generated key: "+key); + throw new Error("Generated key does not decode at all. See stdout."); + } + function testMatch(name, x, y) { + if (x != y) { + println("key match error ("+name+"): ["+x+"] != ["+y+"]"); + println(" key data: "+keydataString()); + println(" generated key: "+key); + println(" decoded key: "+info.toSource()); + throw new Error(name+" mismatch. see stdout."); + } + } + testMatch("personName", info.personName, name); + testMatch("orgName", info.organizationName, org); + testMatch("expires", +info.expiresDate, +expires); + testMatch("editionName", info.editionName, licensing.getEditionName(editionId)); + testMatch("userQuota", +info.userQuota, +userQuota); + } + + testLicense("aaron", "test", +(new Date)+1000*60*60*24*30, licensing.getEditionId('PRIVATE_NETWORK_EVALUATION'), 1001); + + for (var editionId = 0; editionId < 3; editionId++) { + for (var unlimitedUsers = 0; unlimitedUsers <= 1; unlimitedUsers++) { + for (var noExpiry = 0; noExpiry <= 1; noExpiry++) { + for (var j = 0; j < 100; j++) { + var name = stringutils.randomString(1+r.nextInt(39)); + var org = stringutils.randomString(1+r.nextInt(39)); + var expires = null; + if (noExpiry == 0) { + expires = +(new Date)+(1000*60*60*24*r.nextInt(100)); + } + var userQuota = -1; + if (unlimitedUsers == 1) { + userQuota = r.nextInt(1e6); + } + + testLicense(name, org, expires, editionId, userQuota); + } + } + } + } + + // test that all previously generated keys continue to decode. + var historicalKeys = sqlobj.selectMulti('eepnet_signups', {}, {}); + historicalKeys.forEach(function(d) { + var key = d.licenseKey; + if (key && !licensing.isValidKey(key)) { + throw new Error("Historical license key no longer validates: "+key); + } + }); + +} + + + diff --git a/trunk/etherpad/src/etherpad/testing/unit_tests/t0003_persistent_vars.js b/trunk/etherpad/src/etherpad/testing/unit_tests/t0003_persistent_vars.js new file mode 100644 index 0000000..0898fbe --- /dev/null +++ b/trunk/etherpad/src/etherpad/testing/unit_tests/t0003_persistent_vars.js @@ -0,0 +1,42 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("sqlbase.persistent_vars"); + +import("stringutils"); + +import("etherpad.testing.testutils.*"); + +function run() { + var varname = stringutils.randomString(50); + var varval = stringutils.randomString(50); + + var x = persistent_vars.get(varname); + assertTruthy(!x); + + persistent_vars.put(varname, varval); + + for (var i = 0; i < 3; i++) { + x = persistent_vars.get(varname); + assertTruthy(x == varval); + } + + persistent_vars.remove(varname); + + var x = persistent_vars.get(varname); + assertTruthy(!x); +} + diff --git a/trunk/etherpad/src/etherpad/testing/unit_tests/t0004_sqlobj.js b/trunk/etherpad/src/etherpad/testing/unit_tests/t0004_sqlobj.js new file mode 100644 index 0000000..7f8c996 --- /dev/null +++ b/trunk/etherpad/src/etherpad/testing/unit_tests/t0004_sqlobj.js @@ -0,0 +1,214 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("jsutils.*"); +import("stringutils"); + +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); + +import("etherpad.globals.*"); +import("etherpad.testing.testutils.*"); + +function run() { + cleanUpTables(); + testGeneral(); + testAlterColumn(); + cleanUpTables(); +} + +function _getTestTableName() { + return 'sqlobj_unit_test_'+stringutils.randomString(10); +} + +function testGeneral() { + + if (isProduction()) { + return; // we dont run this in productin! + } + + // create a test table + var tableName = _getTestTableName(); + + sqlobj.createTable(tableName, { + id: sqlobj.getIdColspec(), + varChar: 'VARCHAR(128)', + dateTime: sqlobj.getDateColspec("NOT NULL"), + int11: 'INT', + tinyInt: sqlobj.getBoolColspec("DEFAULT 0") + }); + + // add some columns + sqlobj.addColumns(tableName, { + a: 'VARCHAR(256)', + b: 'VARCHAR(256)', + c: 'VARCHAR(256)', + d: 'VARCHAR(256)' + }); + + // drop columns + sqlobj.dropColumn(tableName, 'c'); + sqlobj.dropColumn(tableName, 'd'); + + // list tables and make sure it contains tableName + var l = sqlobj.listTables(); + var found = false; + l.forEach(function(x) { + if (x == tableName) { found = true; } + }); + assertTruthy(found); + + if (sqlcommon.isMysql()) { + for (var i = 0; i < 3; i++) { + ['MyISAM', 'InnoDB'].forEach(function(e) { + sqlobj.setTableEngine(tableName, e); + assertTruthy(e == sqlobj.getTableEngine(tableName)); + }); + } + } + + sqlobj.createIndex(tableName, ['a', 'b']); + sqlobj.createIndex(tableName, ['int11', 'a', 'b']); + + // test null columns + for (var i = 0; i < 10; i++) { + var id = sqlobj.insert(tableName, {dateTime: new Date(), a: null, b: null}); + sqlobj.deleteRows(tableName, {id: id}); + } + + //---------------------------------------------------------------- + // data management + //---------------------------------------------------------------- + + // insert + selectSingle + function _randomDate() { + // millisecond accuracy is lost in DB. + var d = +(new Date); + d = Math.round(d / 1000) * 1000; + return new Date(d); + } + var obj_data_list = []; + for (var i = 0; i < 40; i++) { + var obj_data = { + varChar: stringutils.randomString(20), + dateTime: _randomDate(), + int11: +(new Date) % 10000, + tinyInt: !!(+(new Date) % 2), + a: "foo", + b: "bar" + }; + obj_data_list.push(obj_data); + + var obj_id = sqlobj.insert(tableName, obj_data); + var obj_result = sqlobj.selectSingle(tableName, {id: obj_id}); + + assertTruthy(obj_result.id == obj_id); + keys(obj_data).forEach(function(k) { + var d1 = obj_data[k]; + var d2 = obj_result[k]; + if (k == "dateTime") { + d1 = +d1; + d2 = +d2; + } + if (d1 != d2) { + throw Error("result mismatch ["+k+"]: "+d1+" != "+d2); + } + }); + } + + // selectMulti: no constraints, no options + var obj_result_list = sqlobj.selectMulti(tableName, {}, {}); + assertTruthy(obj_result_list.length == obj_data_list.length); + // orderBy + ['int11', 'a', 'b'].forEach(function(colName) { + obj_result_list = sqlobj.selectMulti(tableName, {}, {orderBy: colName}); + assertTruthy(obj_result_list.length == obj_data_list.length); + for (var i = 1; i < obj_result_list.length; i++) { + assertTruthy(obj_result_list[i-1][colName] <= obj_result_list[i][colName]); + } + + obj_result_list = sqlobj.selectMulti(tableName, {}, {orderBy: "-"+colName}); + assertTruthy(obj_result_list.length == obj_data_list.length); + for (var i = 1; i < obj_result_list.length; i++) { + assertTruthy(obj_result_list[i-1][colName] >= obj_result_list[i][colName]); + } + }); + + // selectMulti: with constraints + var obj_result_list1 = sqlobj.selectMulti(tableName, {tinyInt: true}, {}); + var obj_result_list2 = sqlobj.selectMulti(tableName, {tinyInt: false}, {}); + assertTruthy((obj_result_list1.length + obj_result_list2.length) == obj_data_list.length); + obj_result_list1.forEach(function(o) { + assertTruthy(o.tinyInt == true); + }); + obj_result_list2.forEach(function(o) { + assertTruthy(o.tinyInt == false); + }); + + // updateSingle + obj_result_list1.forEach(function(o) { + o.a = "ttt"; + sqlobj.updateSingle(tableName, {id: o.id}, o); + }); + // update + sqlobj.update(tableName, {tinyInt: false}, {a: "fff"}); + // verify + obj_result_list = sqlobj.selectMulti(tableName, {}, {}); + obj_result_list.forEach(function(o) { + if (o.tinyInt) { + assertTruthy(o.a == "ttt"); + } else { + assertTruthy(o.a == "fff"); + } + }); + + // deleteRows + sqlobj.deleteRows(tableName, {a: "ttt"}); + sqlobj.deleteRows(tableName, {a: "fff"}); + // verify + obj_result_list = sqlobj.selectMulti(tableName, {}, {}); + assertTruthy(obj_result_list.length == 0); +} + +function cleanUpTables() { + // delete testing table (and any other old testing tables) + sqlobj.listTables().forEach(function(t) { + if (t.indexOf("sqlobj_unit_test") == 0) { + sqlobj.dropTable(t); + } + }); +} + +function testAlterColumn() { + var tableName = _getTestTableName(); + + sqlobj.createTable(tableName, { + x: 'INT', + a: 'INT NOT NULL', + b: 'INT NOT NULL' + }); + + if (sqlcommon.isMysql()) { + sqlobj.modifyColumn(tableName, 'a', 'INT'); + sqlobj.modifyColumn(tableName, 'b', 'INT'); + } else { + sqlobj.alterColumn(tableName, 'a', 'NULL'); + sqlobj.alterColumn(tableName, 'b', 'NULL'); + } + + sqlobj.insert(tableName, {a: 5}); +} + diff --git a/trunk/etherpad/src/etherpad/testing/unit_tests/t0005_easysync.js b/trunk/etherpad/src/etherpad/testing/unit_tests/t0005_easysync.js new file mode 100644 index 0000000..9cd3f21 --- /dev/null +++ b/trunk/etherpad/src/etherpad/testing/unit_tests/t0005_easysync.js @@ -0,0 +1,22 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import("etherpad.collab.ace.easysync2_tests"); + +function run() { + easysync2_tests.runTests(); +}
\ No newline at end of file diff --git a/trunk/etherpad/src/etherpad/usage_stats/usage_stats.js b/trunk/etherpad/src/etherpad/usage_stats/usage_stats.js new file mode 100644 index 0000000..59074ed --- /dev/null +++ b/trunk/etherpad/src/etherpad/usage_stats/usage_stats.js @@ -0,0 +1,162 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("execution"); +import("sqlbase.sqlobj"); +import("sqlbase.sqlcommon"); +import("jsutils.*"); +import("fastJSON"); + +import("etherpad.log"); +import("etherpad.log.frontendLogFileName"); +import("etherpad.statistics.statistics"); +import("fileutils.eachFileLine"); + +jimport("java.lang.System.out.println"); +jimport("java.io.BufferedReader"); +jimport("java.io.FileReader"); +jimport("java.io.File"); +jimport("java.awt.Color"); + +jimport("org.jfree.chart.ChartFactory"); +jimport("org.jfree.chart.ChartUtilities"); +jimport("org.jfree.chart.JFreeChart"); +jimport("org.jfree.chart.axis.DateAxis"); +jimport("org.jfree.chart.axis.NumberAxis"); +jimport("org.jfree.chart.plot.XYPlot"); +jimport("org.jfree.chart.renderer.xy.XYLineAndShapeRenderer"); +jimport("org.jfree.data.time.Day"); +jimport("org.jfree.data.time.TimeSeries"); +jimport("org.jfree.data.time.TimeSeriesCollection"); + +//---------------------------------------------------------------- +// Database reading/writing +//---------------------------------------------------------------- + + +function _listStats(statName) { + return sqlobj.selectMulti('statistics', {name: statName}, {orderBy: '-timestamp'}); +} + +// public accessor +function getStatData(statName) { + return _listStats(statName); +} + +//---------------------------------------------------------------- +// HTML & Graph generating +//---------------------------------------------------------------- + +function respondWithGraph(statName) { + var width = 500; + var height = 300; + if (request.params.size) { + var parts = request.params.size.split('x'); + width = +parts[0]; + height = +parts[1]; + } + + var dataset = new TimeSeriesCollection(); + var hideLegend = true; + + switch (statistics.getStatData(statName).plotType) { + case 'line': + var ts = new TimeSeries(statName); + + _listStats(statName).forEach(function(stat) { + var day = new Day(new java.util.Date(stat.timestamp * 1000)); + ts.addOrUpdate(day, fastJSON.parse(stat.value).value); + }); + dataset.addSeries(ts); + break; + case 'topValues': + hideLegend = false; + var stats = _listStats(statName); + if (stats.length == 0) break; + var latestStat = fastJSON.parse(stats[0].value); + var valuesToWatch = []; + var series = {}; + var nLines = 5; + function forEachFirstN(n, stat, f) { + for (var i = 0; i < Math.min(n, stat.topValues.length); i++) { + f(stat.topValues[i].value, stat.topValues[i].count); + } + } + forEachFirstN(nLines, latestStat, function(value, count) { + valuesToWatch.push(value); + series[value] = new TimeSeries(value); + }); + stats.forEach(function(stat) { + var day = new Day(new java.util.Date(stat.timestamp*1000)); + var statData = fastJSON.parse(stat.value); + valuesToWatch.forEach(function(value) { series[value].addOrUpdate(day, 0); }) + forEachFirstN(nLines, statData, function(value, count) { + if (series[value]) { + series[value].addOrUpdate(day, count); + } + }); + }); + valuesToWatch.forEach(function(value) { + dataset.addSeries(series[value]); + }); + break; + case 'histogram': + hideLegend = false; + var stats = _listStats(statName); + percentagesToGraph = ["50", "90", "100"]; + series = {}; + percentagesToGraph.forEach(function(pct) { + series[pct] = new TimeSeries(pct+"%"); + dataset.addSeries(series[pct]); + }); + if (stats.length == 0) break; + stats.forEach(function(stat) { + var day = new Day(new java.util.Date(stat.timestamp*1000)); + var statData = fastJSON.parse(stat.value); + eachProperty(series, function(pct, timeseries) { + timeseries.addOrUpdate(day, statData[pct] || 0); + }); + }); + break; + } + + var domainAxis = new DateAxis(""); + var rangeAxis = new NumberAxis(); + var renderer = new XYLineAndShapeRenderer(); + + var numSeries = dataset.getSeriesCount(); + var colors = [Color.blue, Color.red, Color.green, Color.orange, Color.pink, Color.magenta]; + for (var i = 0; i < numSeries; ++i) { + renderer.setSeriesPaint(i, colors[i]); + renderer.setSeriesShapesVisible(i, false); + } + + var plot = new XYPlot(dataset, domainAxis, rangeAxis, renderer); + + var chart = new JFreeChart(plot); + chart.setTitle(statName); + if (hideLegend) { + chart.removeLegend(); + } + + var jos = new java.io.ByteArrayOutputStream(); + ChartUtilities.writeChartAsJPEG( + jos, 1.0, chart, width, height); + + response.setContentType('image/jpeg'); + response.writeBytes(jos.toByteArray()); +} + diff --git a/trunk/etherpad/src/etherpad/utils.js b/trunk/etherpad/src/etherpad/utils.js new file mode 100644 index 0000000..da9972f --- /dev/null +++ b/trunk/etherpad/src/etherpad/utils.js @@ -0,0 +1,396 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("exceptionutils"); +import("fileutils.{readFile,fileLastModified}"); +import("ejs.EJS"); +import("funhtml.*"); +import("stringutils"); +import("stringutils.startsWith"); +import("jsutils.*"); + +import("etherpad.sessions"); +import("etherpad.sessions.getSession"); +import("etherpad.globals.*"); +import("etherpad.helpers"); +import("etherpad.collab.collab_server"); +import("etherpad.pad.model"); +import("etherpad.pro.domains"); +import("etherpad.pne.pne_utils"); +import("etherpad.pro.pro_utils"); +import("etherpad.pro.pro_config"); +import("etherpad.pro.pro_accounts"); +import("etherpad.pro.pro_accounts.getSessionProAccount"); + +jimport("java.lang.System.out.print"); +jimport("java.lang.System.out.println"); + +//---------------------------------------------------------------- +// utilities +//---------------------------------------------------------------- + +// returns globally-unique padId +function randomUniquePadId() { + var id = stringutils.randomString(10); + while (model.accessPadGlobal(id, function(p) { return p.exists(); }, "r")) { + id = stringutils.randomString(10); + } + return id; +} + +//---------------------------------------------------------------- +// template rendering +//---------------------------------------------------------------- + +function renderTemplateAsString(filename, data) { + data = data || {}; + data.helpers = helpers; // global helpers + + var f = "/templates/"+filename; + if (! appjet.scopeCache.ejs) { + appjet.scopeCache.ejs = {}; + } + var cacheObj = appjet.scopeCache.ejs[filename]; + if (cacheObj === undefined || fileLastModified(f) > cacheObj.mtime) { + var templateText = readFile(f); + cacheObj = {}; + cacheObj.tmpl = new EJS({text: templateText, name: filename}); + cacheObj.mtime = fileLastModified(f); + appjet.scopeCache.ejs[filename] = cacheObj; + } + var html = cacheObj.tmpl.render(data); + return html; +} + +function renderTemplate(filename, data) { + response.write(renderTemplateAsString(filename, data)); + if (request.acceptsGzip) { + response.setGzip(true); + } +} + +function renderHtml(bodyFileName, data) { + var bodyHtml = renderTemplateAsString(bodyFileName, data); + response.write(renderTemplateAsString("html.ejs", {bodyHtml: bodyHtml})); + if (request.acceptsGzip) { + response.setGzip(true); + } +} + +function renderFramedHtml(contentHtml) { + var getContentHtml; + if (typeof(contentHtml) == 'function') { + getContentHtml = contentHtml; + } else { + getContentHtml = function() { return contentHtml; } + } + + var template = "framed/framedpage.ejs"; + if (isProDomainRequest()) { + template = "framed/framedpage-pro.ejs"; + } + + renderHtml(template, { + renderHeader: renderMainHeader, + renderFooter: renderMainFooter, + getContentHtml: getContentHtml, + isProDomainRequest: isProDomainRequest(), + renderGlobalProNotice: pro_utils.renderGlobalProNotice + }); +} + +function renderFramed(bodyFileName, data) { + function _getContentHtml() { + return renderTemplateAsString(bodyFileName, data); + } + renderFramedHtml(_getContentHtml); +} + +function renderFramedError(error) { + var content = DIV({className: 'fpcontent'}, + DIV({style: "padding: 2em 1em;"}, + DIV({style: "padding: 1em; border: 1px solid #faa; background: #fdd;"}, + B("Error: "), error))); + renderFramedHtml(content); +} + +function renderNotice(bodyFileName, data) { + renderNoticeString(renderTemplateAsString(bodyFileName, data)); +} + +function renderNoticeString(contentHtml) { + renderFramed("notice.ejs", {content: contentHtml}); +} + +function render404(noStop) { + response.reset(); + response.setStatusCode(404); + renderFramedHtml(DIV({className: "fpcontent"}, + DIV({style: "padding: 2em 1em;"}, + DIV({style: "border: 1px solid #aaf; background: #def; padding: 1em; font-size: 150%;"}, + "404 not found: "+request.path)))); + if (! noStop) { + response.stop(); + } +} + +function render500(ex) { + response.reset(); + response.setStatusCode(500); + var trace = null; + if (ex && (!isProduction())) { + trace = exceptionutils.getStackTracePlain(ex); + } + renderFramed("500_body.ejs", {trace: trace}); +} + +function _renderEtherpadDotComHeader(data) { + if (!data) { + data = {selected: ''}; + } + data.html = stringutils.html; + data.UL = UL; + data.LI = LI; + data.A = A; + data.isPNE = isPrivateNetworkEdition(); + return renderTemplateAsString("framed/framedheader.ejs", data); +} + +function _renderProHeader(data) { + if (!pro_accounts.isAccountSignedIn()) { + return '<div style="height: 140px;"> </div>'; + } + + var r = domains.getRequestDomainRecord(); + if (!data) { data = {}; } + data.navSelection = (data.navSelection || appjet.requestCache.proTopNavSelection || ''); + data.proDomainOrgName = pro_config.getConfig().siteName; + data.isPNE = isPrivateNetworkEdition(); + data.account = getSessionProAccount(); + data.validLicense = pne_utils.isServerLicensed(); + data.pneTrackerHtml = pne_utils.pneTrackerHtml(); + data.isAnEtherpadAdmin = sessions.isAnEtherpadAdmin(); + data.fullSuperdomain = pro_utils.getFullSuperdomainHost(); + return renderTemplateAsString("framed/framedheader-pro.ejs", data); +} + +function renderMainHeader(data) { + if (isProDomainRequest()) { + return _renderProHeader(data); + } else { + return _renderEtherpadDotComHeader(data); + } +} + +function renderMainFooter() { + return renderTemplateAsString("framed/framedfooter.ejs", { + isProDomainRequest: isProDomainRequest() + }); +} + +//---------------------------------------------------------------- +// isValidEmail +//---------------------------------------------------------------- + +// TODO: make better and use the better version on the client in +// various places as well (pad.js and etherpad.js) +function isValidEmail(x) { + return (x && + ((x.length > 0) && + (x.match(/^[\w\.\_\+\-]+\@[\w\_\-]+\.[\w\_\-\.]+$/)))); +} + +//---------------------------------------------------------------- + +function timeAgo(d, now) { + if (!now) { now = new Date(); } + + function format(n, word) { + n = Math.round(n); + return ('' + n + ' ' + word + (n != 1 ? 's' : '') + ' ago'); + } + + d = (+now - (+d)) / 1000; + if (d < 60) { return format(d, 'second'); } + d /= 60; + if (d < 60) { return format(d, 'minute'); } + d /= 60; + if (d < 24) { return format(d, 'hour'); } + d /= 24; + return format(d, 'day'); +}; + + +//---------------------------------------------------------------- +// linking to a set of new CGI parameters +//---------------------------------------------------------------- +function qpath(m) { + var q = {}; + if (request.query) { + request.query.split('&').forEach(function(kv) { + if (kv) { + var parts = kv.split('='); + q[parts[0]] = parts[1]; + } + }); + } + eachProperty(m, function(k,v) { + q[k] = v; + }); + var r = request.path + '?'; + eachProperty(q, function(k,v) { + if (v !== undefined && v !== null) { + r += ('&' + k + '=' + v); + } + }); + return r; +} + +//---------------------------------------------------------------- + +function ipToHostname(ip) { + var DNS = Packages.org.xbill.DNS; + + if (!DNS.Address.isDottedQuad(ip)) { + return null + } + + try { + var addr = DNS.Address.getByAddress(ip); + return DNS.Address.getHostName(addr); + } catch (ex) { + return null; + } +} + +function extractGoogleQuery(ref) { + ref = String(ref); + ref = ref.toLowerCase(); + if (!(ref.indexOf("google") >= 0)) { + return ""; + } + + ref = ref.split('?')[1]; + + var q = ""; + ref.split("&").forEach(function(x) { + var parts = x.split("="); + if (parts[0] == "q") { + q = parts[1]; + } + }); + + q = decodeURIComponent(q); + q = q.replace(/\+/g, " "); + + return q; +} + +function isTestEmail(x) { + return (x.indexOf("+appjetseleniumtest+") >= 0); +} + +function isPrivateNetworkEdition() { + return pne_utils.isPNE(); +} + +function isProDomainRequest() { + return pro_utils.isProDomainRequest(); +} + +function hasOffice() { + return appjet.config["etherpad.soffice"] || appjet.config["etherpad.sofficeConversionServer"]; +} + +////////// console progress bar + +function startConsoleProgressBar(barWidth, updateIntervalSeconds) { + barWidth = barWidth || 40; + updateIntervalSeconds = ((typeof updateIntervalSeconds) == "number" ? updateIntervalSeconds : 1.0); + + var unseenStatus = null; + var lastPrintTime = 0; + var column = 0; + + function replaceLineWith(str) { + //print((new Array(column+1)).join('\b')+str); + print('\r'+str); + column = str.length; + } + + var bar = { + update: function(frac, msg, force) { + var t = +new Date(); + if ((!force) && ((t - lastPrintTime)/1000 < updateIntervalSeconds)) { + unseenStatus = {frac:frac, msg:msg}; + } + else { + var pieces = []; + pieces.push(' ', (' '+Math.round(frac*100)).slice(-3), '%', ' ['); + var barEndLoc = Math.max(0, Math.min(barWidth-1, Math.floor(frac*barWidth))); + for(var i=0;i<barWidth;i++) { + if (i < barEndLoc) pieces.push('='); + else if (i == barEndLoc) pieces.push('>'); + else pieces.push(' '); + } + pieces.push('] ', msg || ''); + replaceLineWith(pieces.join('')); + + unseenStatus = null; + lastPrintTime = t; + } + }, + finish: function() { + if (unseenStatus) { + bar.update(unseenStatus.frac, unseenStatus.msg, true); + } + println(); + } + }; + + println(); + bar.update(0, null, true); + + return bar; +} + +function isStaticRequest() { + return (startsWith(request.path, '/static/') || + startsWith(request.path, '/favicon.ico') || + startsWith(request.path, '/robots.txt')); +} + +function httpsHost(h) { + h = h.split(":")[0]; // strip any existing port + if (appjet.config.listenSecurePort != "443") { + h = (h + ":" + appjet.config.listenSecurePort); + } + return h; +} + +function httpHost(h) { + h = h.split(":")[0]; // strip any existing port + if (appjet.config.listenPort != "80") { + h = (h + ":" + appjet.config.listenPort); + } + return h; +} + +function toJavaException(e) { + var exc = ((e instanceof java.lang.Throwable) && e) || e.rhinoException || e.javaException || + new java.lang.Throwable(e.message+"/"+e.fileName+"/"+e.lineNumber); + return exc; +} |