Commit 12557874 authored by Benoit Lavenier's avatar Benoit Lavenier

[enh] Add share URL, with open graph tag - fix issue #35

parent 076c4fb3
[submodule "duniter4j-cesium/src/main/cesium"]
path = duniter4j-cesium/src/main/cesium
url = git://github.com/duniter/cesium.git
[submodule "duniter4j-elasticsearch/src/main/resources/cities"]
path = duniter4j-elasticsearch/src/main/resources/cities
url = git://github.com/David-Haim/CountriesToCitiesJSON.git
......@@ -249,4 +249,19 @@ duniter.subscription.enable: false
#
# Email subscription: URL to a Cesium site, for links in the email content (default: https://g1.duniter.fr)
#
# duniter.subscription.email.cesium.url: 'http://domain.com/cesium'
\ No newline at end of file
# duniter.subscription.email.cesium.url: 'http://domain.com/cesium'
#
# ---------------------------------- Duniter4j User (profile, message) module -------------------
#
#
# Share link: `og:site_name` (default: 'Cesium')
#
# duniter.user.share.site.name: 'Cesium - Ğ1'
#
# Share link: `og:url` - URL to a Cesium site, for links in the email content (default: https://g1.duniter.fr)
#
# duniter.share.cesium.url: 'https://domain.com/cesium'
#
# Share link: Base URL of the ES cluster, to resolve `og:image` URL (default: none => /!\ Will use relative image path)
#
# duniter.share.base.url: 'https://data.domain.com'
\ No newline at end of file
......@@ -251,4 +251,19 @@ duniter.subscription.enable: true
#
# Email subscription: URL to a Cesium site, for links in the email content (default: https://g1.duniter.fr)
#
#duniter.subscription.email.cesium.url: 'https://domain.com/cesium'
\ No newline at end of file
#duniter.subscription.email.cesium.url: 'https://domain.com/cesium'
#
# ---------------------------------- Duniter4j User (profile, message) module -------------------
#
#
# Share link: og:site_name (default: 'Cesium')
#
# duniter.user.share.site.name: 'Cesium - Ğ1'
#
# Share link : URL to a Cesium site, for links in the email content (default: https://g1.duniter.fr)
#
#duniter.share.cesium.url: 'https://domain.com/cesium'
#
# Share link : Base URL of cluster, to resolve image (default: none => /!\ Will use relative image path)
#
#duniter.share.base.url: 'http://localhost:9200'
\ No newline at end of file
......@@ -76,6 +76,14 @@
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.antlr</groupId>
<artifactId>stringtemplate</artifactId>
<version>${stringtemplate.version}</version>
<scope>compile</scope>
</dependency>
<!-- JNA (need for OS shutdown hook) -->
<dependency>
<groupId>net.java.dev.jna</groupId>
......
......@@ -95,4 +95,17 @@ public class RestImageAttachmentAction extends BaseRestHandler {
}
});
}
public static String computeImageUrl(String index,
String type,
String id,
String imageField,
String contentType) {
int lastSlashIndex = contentType != null ? contentType.lastIndexOf('/') : -1;
String extension = (lastSlashIndex >= 0) ? contentType.substring(lastSlashIndex+1) : contentType;
return String.format("/%s/%s/%s/_image/%s.%s", index, type, id, imageField, extension);
}
}
\ No newline at end of file
package org.duniter.elasticsearch.rest.share;
import org.apache.http.entity.ContentType;
import org.duniter.core.exception.BusinessException;
import org.duniter.core.util.Preconditions;
import org.duniter.core.util.StringUtils;
import org.duniter.elasticsearch.exception.DuniterElasticsearchException;
import org.duniter.elasticsearch.rest.XContentThrowableRestResponse;
import org.duniter.elasticsearch.util.opengraph.OGData;
import org.duniter.elasticsearch.util.springtemplate.STUtils;
import org.elasticsearch.client.Client;
import org.elasticsearch.common.logging.ESLogger;
import org.elasticsearch.common.logging.Loggers;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.rest.*;
import org.nuiton.i18n.I18n;
import org.stringtemplate.v4.*;
import java.util.Locale;
import static org.elasticsearch.rest.RestRequest.Method.GET;
import static org.elasticsearch.rest.RestStatus.OK;
public abstract class AbstractRestShareLinkAction extends BaseRestHandler {
protected final ESLogger log;
public interface OGDataResolver {
OGData resolve(String id) throws DuniterElasticsearchException, BusinessException;
}
private OGDataResolver resolver;
private STGroup templates;
public AbstractRestShareLinkAction(Settings settings, RestController controller, Client client,
String indexName,
String typeName,
OGDataResolver resolver
) {
super(settings, controller, client);
log = Loggers.getLogger("duniter.rest." + indexName, settings, String.format("[%s]", indexName));
controller.registerHandler(GET,
String.format("/%s/%s/{id}/_share", indexName, typeName),
this);
this.resolver = resolver;
// Configure springtemplate engine
this.templates = STUtils.newSTGroup("org/duniter/elasticsearch/templates");
Preconditions.checkNotNull(this.templates.getInstanceOf("html_share"), "Unable to load ST template for share page");
}
@Override
protected void handleRequest(final RestRequest request, RestChannel restChannel, Client client) throws Exception {
String id = request.param("id");
try {
OGData data = resolver.resolve(id);
Preconditions.checkNotNull(data);
Preconditions.checkNotNull(data.title);
// Compute HTML content
ST template = templates.getInstanceOf("html_share");
template.add("type", data.type);
template.add("title", data.title);
template.add("summary", StringUtils.truncate(data.description, 500));
template.add("description", data.description);
template.add("siteName", data.siteName);
template.add("image", data.image);
template.add("url", data.url);
template.add("locale", data.locale);
if (StringUtils.isNotBlank(data.url)) {
Locale locale = data.locale != null ? new Locale(data.locale) : I18n.getDefaultLocale();
template.add("redirectMessage", I18n.l(locale, "duniter4j.share.redirection.help"));
}
String html = template.render();
restChannel.sendResponse(new BytesRestResponse(OK, ContentType.TEXT_HTML.getMimeType(), html));
}
catch(DuniterElasticsearchException | BusinessException e) {
log.error(e.getMessage(), e);
restChannel.sendResponse(new XContentThrowableRestResponse(request, e));
}
catch(Exception e) {
log.error(e.getMessage(), e);
}
}
}
package org.duniter.elasticsearch.util.opengraph;
public class OGData {
public String type;
public String title;
public String description;
public String image;
public String url;
public String locale;
public String imageType;
public String siteName;
}
package org.duniter.elasticsearch.util.springtemplate;
/*-
* #%L
* Duniter4j :: ElasticSearch Subscription plugin
* %%
* Copyright (C) 2014 - 2017 EIS
* %%
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public
* License along with this program. If not, see
* <http://www.gnu.org/licenses/gpl-3.0.html>.
* #L%
*/
import org.stringtemplate.v4.AttributeRenderer;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.Locale;
public class DateRenderer implements AttributeRenderer {
public DateRenderer() {
}
public String toString(Object o, String formatString, Locale locale) {
if(formatString == null) {
formatString = "short";
}
Date d;
if(o instanceof Calendar) {
d = ((Calendar)o).getTime();
} else {
d = (Date)o;
}
Integer styleI = (Integer)org.stringtemplate.v4.DateRenderer.formatToInt.get(formatString);
Object f;
if(styleI == null) {
f = new SimpleDateFormat(formatString, locale);
} else {
int style = styleI.intValue();
if(formatString.startsWith("date:")) {
f = DateFormat.getDateInstance(style, locale);
} else if(formatString.startsWith("time:")) {
f = DateFormat.getTimeInstance(style, locale);
} else {
f = DateFormat.getDateTimeInstance(style, style, locale);
}
}
return ((DateFormat)f).format(d);
}
}
\ No newline at end of file
package org.duniter.elasticsearch.util.springtemplate;
import org.stringtemplate.v4.DateRenderer;
import org.stringtemplate.v4.STGroup;
import org.stringtemplate.v4.STGroupDir;
import org.stringtemplate.v4.StringRenderer;
import java.util.Date;
public class STUtils {
private STUtils() {
/*help class*/
}
public static STGroup newSTGroup(String dirName) {
// Configure springtemplate engine
STGroup templates = new STGroupDir(dirName, '$', '$');
templates.registerRenderer(Date.class, new DateRenderer());
templates.registerRenderer(String.class, new StringRenderer());
return templates;
}
}
package org.duniter.elasticsearch.util.springtemplate;
/*-
* #%L
* Duniter4j :: ElasticSearch Subscription plugin
* %%
* Copyright (C) 2014 - 2017 EIS
* %%
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public
* License along with this program. If not, see
* <http://www.gnu.org/licenses/gpl-3.0.html>.
* #L%
*/
import org.duniter.core.client.model.ModelUtils;
import org.duniter.core.util.CollectionUtils;
import org.nuiton.i18n.I18n;
import java.util.Locale;
/**
* Add format capabilities: i18n, pubkey
* Created by blavenie on 10/04/17.
*/
public class StringRenderer extends org.stringtemplate.v4.StringRenderer{
@Override
public String toString(Object o, String formatString, Locale locale) {
return formatString == null ? (String)o :
(formatString.equals("pubkey") ? ModelUtils.minifyPubkey((String)o) :
(formatString.startsWith("i18n") ? toI18nString(o, formatString, locale) :
super.toString(o, formatString, locale)));
}
protected String toI18nString(Object key, String formatString, Locale locale) {
String[] params = formatString.startsWith("i18n:") ? formatString.substring(5).split(",") : null;
if (CollectionUtils.isNotEmpty(params)) {
return I18n.l(locale, key.toString(), params);
}
return I18n.l(locale, key.toString());
}
}
......@@ -47,6 +47,7 @@ duniter4j.job.stopped=
duniter4j.job.stopping=
duniter4j.job.success=
duniter4j.service.waitThenRetry=Error [%s]... will retry [%s/%s]
duniter4j.share.redirection.help=If you are not redirected automatically, follow this link\:
duniter4j.task.issuer.system=System
duniter4j.task.starting=Starting task...
duniter4j.threadPool.clusterHealthStatus.changed=Cluster health status changed to [%s]
......@@ -49,6 +49,7 @@ duniter4j.job.stopped=
duniter4j.job.stopping=
duniter4j.job.success=
duniter4j.service.waitThenRetry=Echec [%s]... tentative [%s/%s]
duniter4j.share.redirection.help=Si vous n'êtes pas redirigé automatiquement, cliquez sur le lien suivant \:
duniter4j.task.issuer.system=Système
duniter4j.task.starting=Démarrage du traitement...
duniter4j.threadPool.clusterHealthStatus.changed=Cluster health status changed to [%s]
html_share(type, title, summary, description, image, siteName, locale, url, redirectMessage) ::= <<
<html prefix="og: http://ogp.me/ns#">
<head>
<meta charset="UTF-8">
$if(siteName)$
<title>$siteName$ | $title$</title>
$else$
<title>$title$</title>
$endif$
$if(type)$
<meta property="og:type" content="$type$" />
$else$
<meta property="og:type" content="website" />
$endif$
<meta property="og:title" content="$title$" />
$if(summary)$
<meta property="og:description" content="$summary$" />
$else$
<meta property="og:description" content="$description$" />
$endif$
$if(siteName)$
<meta property="og:site_name" content="$siteName$" />
$endif$
$if(image)$
<meta property="og:image" content="$image$" />
$endif$
$if(locale)$
<meta property="og:locale" content="$locale$" />
$endif$
$if(url)$
<meta property="og:url" content="$url$" />
<script type="text/javascript">
window.location.href = "$url$"
</script>
<META HTTP-EQUIV="Refresh" CONTENT="0; URL=$url$">
$endif$
</head>
<body>
$if(image)$
<p>
<img src="$image$"/>
</p>
$endif$
<h1>$title$</h1>
<p>$description$</p>
$if(url)$
<p>
$redirectMessage$ <a href='$url$'>$title$</a>.
</p>
$endif$
</body>
</html>
>>
......@@ -46,7 +46,7 @@
<groupId>org.antlr</groupId>
<artifactId>stringtemplate</artifactId>
<version>${stringtemplate.version}</version>
<scope>compile</scope>
<scope>provided</scope>
</dependency>
<!-- Unit test -->
......
......@@ -51,6 +51,7 @@ import org.duniter.elasticsearch.user.service.AdminService;
import org.duniter.elasticsearch.user.service.MailService;
import org.duniter.elasticsearch.user.service.UserEventService;
import org.duniter.elasticsearch.user.service.UserService;
import org.duniter.elasticsearch.util.springtemplate.STUtils;
import org.elasticsearch.common.inject.Inject;
import org.elasticsearch.common.unit.TimeValue;
import org.nuiton.i18n.I18n;
......@@ -76,6 +77,7 @@ public class SubscriptionService extends AbstractService {
private UserEventService userEventService;
private UserService userService;
private String emailSubjectPrefix;
private STGroup templates;
@Inject
public SubscriptionService(Duniter4jClient client,
......@@ -100,6 +102,11 @@ public class SubscriptionService extends AbstractService {
if (StringUtils.isNotBlank(emailSubjectPrefix)) {
emailSubjectPrefix += " "; // add one trailing space
}
// Configure springtemplate engine
templates = STUtils.newSTGroup("org/duniter/elasticsearch/subscription/templates");
Preconditions.checkNotNull(templates.getInstanceOf("text_email"), "Missing ST template {text_email}");
Preconditions.checkNotNull(templates.getInstanceOf("html_email_content"), "Missing ST template {html_email_content}");
}
public String create(String json) {
......@@ -308,11 +315,7 @@ public class SubscriptionService extends AbstractService {
subscription.getContent().getLocale().split("-") : new String[]{"en", "GB"};
Locale issuerLocale = localParts.length >= 2 ? new Locale(localParts[0].toLowerCase(), localParts[1].toUpperCase()) : new Locale(localParts[0].toLowerCase());
// Configure templates engine
STGroup templates = new STGroupDir("templates", '$', '$');
templates.registerRenderer(Date.class, new DateRenderer());
templates.registerRenderer(String.class, new StringRenderer());
//templates.registerRenderer(Number.class, new NumberRenderer());
// Compute text content
final String text = fillTemplate(
......
grant codeBase "file:${es.path.home}/plugins/duniter4j-es-gchange/"{
grant codeBase "file:${es.path.home}/plugins/duniter4j-es-subscription/"{
permission java.io.FilePermission "/etc/ld.so.conf", "read";
permission java.io.FilePermission "/etc/ld.so.conf.d/*.conf", "read";
permission java.io.FilePermission "/usr/local/lib/*", "read";
......
......@@ -212,8 +212,17 @@ public class PluginSettings extends AbstractLifecycleComponent<PluginSettings> {
return this.nodePubkey;
}
public String getCesiumUrl() {
return this.settings.get("duniter.share.cesium.url", "https://g1.duniter.fr");
}
public String getShareSiteName() {
return this.settings.get("duniter.user.share.site.name", "Cesium");
}
public String getBaseUrl() {
return settings.get("duniter.share.base.url");
}
/* -- protected methods -- */
......
package org.duniter.elasticsearch.user.model;
/*
* #%L
* Duniter4j :: Core Client API
* %%
* Copyright (C) 2014 - 2016 EIS
* %%
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public
* License along with this program. If not, see
* <http://www.gnu.org/licenses/gpl-3.0.html>.
* #L%
*/
import com.fasterxml.jackson.annotation.JsonGetter;
import com.fasterxml.jackson.annotation.JsonSetter;
import org.duniter.core.client.model.elasticsearch.Record;
/**
* Created by blavenie on 01/03/16.
*/
public class Attachment extends Record {
public static final String JSON_PROPERTY_CONTENT_TYPE = "_content_type";
public static final String JSON_PROPERTY_CONTENT = "_content";
public static final String PROPERTY_CONTENT_TYPE = "contentType";
public static final String PROPERTY_CONTENT = "content";
private String contentType;
private String content;
@JsonSetter(JSON_PROPERTY_CONTENT_TYPE)
public void setContentType(String contentType) {
this.contentType = contentType;
}
@JsonGetter(JSON_PROPERTY_CONTENT_TYPE)
public String getContentType() {
return contentType;
}
@JsonGetter(JSON_PROPERTY_CONTENT)
public String getContent() {
return content;
}
@JsonSetter(JSON_PROPERTY_CONTENT)
public void setContent(String content) {
this.content = content;
}
}
......@@ -43,6 +43,7 @@ public class UserProfile extends Record {
private String locale;
private String address;
private String city;
private Attachment avatar;
public String getTitle() {
return title;
......@@ -91,4 +92,12 @@ public class UserProfile extends Record {
public void setCity(String city) {
this.city = city;
}
public Attachment getAvatar() {
return avatar;
}
public void setAvatar(Attachment avatar) {
this.avatar = avatar;
}
}
......@@ -50,6 +50,7 @@ public class RestModule extends AbstractModule implements Module {
bind(RestUserEventMarkAsReadAction.class).asEagerSingleton();
bind(RestUserEventSearchAction.class).asEagerSingleton();
bind(RestUserAvatarAction.class).asEagerSingleton();
bind(RestUserShareLinkAction.class).asEagerSingleton();
// Group
bind(RestGroupIndexAction.class).asEagerSingleton();
......@@ -76,6 +77,7 @@ public class RestModule extends AbstractModule implements Module {
bind(RestPageCommentUpdateAction.class).asEagerSingleton();
bind(RestPageCategoryAction.class).asEagerSingleton();
bind(RestPageImageAction.class).asEagerSingleton();
bind(RestPageShareLinkAction.class).asEagerSingleton();
// Mixed search
bind(RestMixedSearchAction.class).asEagerSingleton();
......
package org.duniter.elasticsearch.user.rest.page;
import com.google.common.html.HtmlEscapers;
import org.duniter.core.exception.BusinessException;
import org.duniter.core.exception.TechnicalException;
import org.duniter.core.util.StringUtils;
import org.duniter.elasticsearch.exception.DuniterElasticsearchException;
import org.duniter.elasticsearch.rest.attachment.RestImageAttachmentAction;
import org.duniter.elasticsearch.rest.share.AbstractRestShareLinkAction;
import org.duniter.elasticsearch.user.PluginSettings;
import org.duniter.elasticsearch.user.dao.page.PageIndexDao;
import org.duniter.elasticsearch.user.dao.page.PageRecordDao;
import org.duniter.elasticsearch.user.model.UserProfile;
import org.duniter.elasticsearch.user.model.page.RegistryRecord;
import org.duniter.elasticsearch.user.service.PageService;
import org.duniter.elasticsearch.user.service.UserService;
import org.duniter.elasticsearch.util.opengraph.OGData;
import org.elasticsearch.client.Client;
import org.elasticsearch.common.inject.Inject;
import org.elasticsearch.common.settings.Settings;
import org.elasticsearch.rest.RestController;
import org.nuiton.i18n.I18n;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.Locale;
public class RestPageShareLinkAction extends AbstractRestShareLinkAction {
@Inject
public RestPageShareLinkAction(final Settings settings, final RestController controller, final Client client,
final PluginSettings pluginSettings,
final PageService service) {
super(settings, controller, client, PageIndexDao.INDEX, PageRecordDao.TYPE,
createResolver(pluginSettings, service));
}
protected static OGDataResolver createResolver(