Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions cal/src/EventDetails.vue
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ export default {
}
// done loading.
const page = buildPage(evt, vm.calStart, to.fullPath);
vm.evt = evt;
vm.evt = evt;
// override the server's shareable with the spa's current page.
evt.shareable = window.location;
vm.loadUnfurl();
vm.$emit("pageLoaded", page);
});
}
Expand All @@ -81,7 +82,8 @@ export default {
const { caldaily_id, slug } = to.params;
return dataPool.getDaily(caldaily_id).then((evt) => {
const page = buildPage(evt, this.calStart, to.fullPath);
this.evt = evt;
this.evt = evt;
this.loadUnfurl();
this.$emit("pageLoaded", page);
}).catch((error) => {
console.warn("event loading error:", error);
Expand All @@ -100,9 +102,36 @@ export default {
},
// the week we came from
calStart: null,
// oEmbed card html for a ridewithgps link in the details (if any)
unfurlHtml: null,
};
},
methods: {
// fetch an oEmbed card for the first ridewithgps link in the details, if
// any. mirrors the legacy calendar's loadUnfurls; fails silently on error
// or CORS. unlike the legacy list (collapsed events), this is a dedicated
// page so the link is always visible — no need to lazy-load.
loadUnfurl() {
this.unfurlHtml = null;
const url = helpers.getUnfurlUrl(this.evt.details);
// oEmbed endpoint is ridewithgps-specific; only it is allow-listed today.
if (!url || !/(^|\.)ridewithgps\.com$/i.test(new URL(url).hostname)) {
return;
}
fetch(`https://ridewithgps.com/oembed?format=json&url=${encodeURIComponent(url)}`)
.then((resp) => (resp.ok ? resp.json() : null))
.then((data) => {
if (data && data.html) {
this.unfurlHtml = data.html;
}
})
.catch(() => { /* fails silently, matches legacy */ });
},
},
computed: {
linkedDetails() {
return helpers.getLinkedDetails(this.evt.details);
},
tags() {
return calTags.buildEventTags(this.evt)
},
Expand Down Expand Up @@ -187,9 +216,8 @@ export default {
</ExternalLink>
</Term>
</dl>
<p class="c-description">
{{evt.details}}
</p>
<p class="c-description" v-html="linkedDetails"></p>
<div v-if="unfurlHtml" class="rwgps-unfurl" v-html="unfurlHtml"></div>
<ul class="c-detail-links" v-if="evt.id">
<li><a :href="shareableLink" class="c-links__share" rel="bookmark">Sharable link</a></li>
<li><a :href="exportLink" class="c-links__export">Export to calendar</a></li>
Expand Down Expand Up @@ -230,6 +258,16 @@ export default {
margin-top: 1em;
padding-top: 1em;
}
.rwgps-unfurl {
margin-top: 0.75em;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}
.rwgps-unfurl iframe {
display: block;
max-width: 100%;
}
.c-detail-links {
display: flex;
justify-content: center;
Expand Down
77 changes: 77 additions & 0 deletions cal/src/calHelpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,42 @@ function within(a, start, end) {
const urlPattern = /^https*:\/\//;
const emailPattern = /.+@.+[.].+/;

// domains we trust enough to auto-link inside free-text event
// descriptions. keeps the description field from becoming an open
// invitation for spam links while still letting people share their
// ridewithgps.com routes. mirrors the legacy calendar's config.js. see #1072.
const LINKABLE_DOMAINS = ['ridewithgps.com'];

const linkableHostPattern = LINKABLE_DOMAINS.map(
(domain) => '(?:[a-z0-9-]+\\.)*' + domain.replace(/\./g, '\\.')
).join('|');

// an optional scheme/www, one of the allow-listed hosts, then an optional path.
// the negative lookbehind keeps it from matching as a suffix of a longer
// word/domain (eg. "evilridewithgps.com") or an email's domain part.
const linkableUrlPattern = new RegExp(
'(?<![\\w.@-])(?:https?:\\/\\/)?(?:' + linkableHostPattern + ')(?:\\/[^\\s<>"\']*)?',
'gi'
);

function escapeHtml(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

// drop trailing sentence punctuation so it doesn't get swallowed into a link
function trimUrl(url) {
return url.replace(/[.,!?;:'")\]}]+$/, '');
}

function normalizeHref(url) {
return /^https?:\/\//i.test(url) ? url : ('https://' + url);
}

function friendlyTime(time) {
return dayjs(time, 'hh:mm:ss').format('h:mm A');
}
Expand Down Expand Up @@ -47,6 +83,47 @@ export default {
// if it's not a link, return nothing
},

// turns plain-text event details into safe html, hyperlinking any
// allow-listed links (eg. ridewithgps.com routes). the rest of the text is
// html-escaped, not left as raw html, since this is free text from event
// organizers. mirrors the legacy calendar's getLinkedDetails (helpers.js).
getLinkedDetails(details) {
if (!details) {
return details;
}

let html = '';
let lastIndex = 0;
let match;
linkableUrlPattern.lastIndex = 0;

while ((match = linkableUrlPattern.exec(details)) !== null) {
const url = trimUrl(match[0]);
const start = match.index;
const end = start + url.length;

html += escapeHtml(details.slice(lastIndex, start));
const href = normalizeHref(url);
html += `<a href="${escapeHtml(href)}" target="_blank" rel="noopener nofollow external" title="Opens in a new window">${escapeHtml(url)}</a>`;

lastIndex = end;
linkableUrlPattern.lastIndex = end;
}
html += escapeHtml(details.slice(lastIndex));
return html;
},

// the first allow-listed url in the details (normalized with a scheme),
// or undefined. used to fetch an oEmbed card. first link wins.
getUnfurlUrl(details) {
if (!details) {
return undefined;
}
linkableUrlPattern.lastIndex = 0;
const match = linkableUrlPattern.exec(details);
return match ? normalizeHref(trimUrl(match[0])) : undefined;
},

//7:00 AM to 9:00 AM
getTimeRange(evt) {
const {endtime, time} = evt;
Expand Down
12 changes: 12 additions & 0 deletions site/themes/s2b_hugo_theme/assets/css/cal/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,18 @@ h3 {
white-space: pre-line;
}

.rwgps-unfurl {
margin-top: 0.75em;
border: 1px solid #ddd;
border-radius: 4px;
overflow: hidden;
}

.rwgps-unfurl iframe {
display: block;
max-width: 100%;
}

.otherButtons a {
text-decoration: none;
}
Expand Down
6 changes: 6 additions & 0 deletions site/themes/s2b_hugo_theme/assets/js/cal/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,9 @@ const DEFAULT_RIDE_LENGTH = '--';
// total number of days to fetch, inclusive of start and end dates;
// minimum of 1, maximum set by server
const DEFAULT_DAYS_TO_FETCH = 10;

// domains we trust enough to auto-link inside free-text event
// descriptions. keeps the description field from becoming an open
// invitation for spam links while still letting people share their
// ridewithgps.com routes. see issue #1072.
const LINKABLE_DOMAINS = ['ridewithgps.com'];
90 changes: 90 additions & 0 deletions site/themes/s2b_hugo_theme/assets/js/cal/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,59 @@
return googleCalUrl.toString();
};

// LINKABLE_DOMAINS is defined in config.js
var linkableHostPattern = LINKABLE_DOMAINS.map(function(domain) {
return '(?:[a-z0-9-]+\\.)*' + domain.replace(/\./g, '\\.');
}).join('|');

// an optional scheme/www, one of the allow-listed hosts, then an optional path.
// the negative lookbehind keeps it from matching as a suffix of a longer
// word/domain (eg. "evilridewithgps.com") or an email's domain part.
var linkableUrlPattern = new RegExp(
'(?<![\\w.@-])(?:https?:\\/\\/)?(?:' + linkableHostPattern + ')(?:\\/[^\\s<>"\']*)?',
'gi'
);

function escapeHtml(text) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}

// turns plain-text event details into safe html, hyperlinking any
// ridewithgps.com links (the rest of the text is html-escaped, not
// left as raw html, since this is free text from event organizers).
$.fn.getLinkedDetails = function(details) {
if (!details) {
return details;
}

var html = '';
var lastIndex = 0;
var match;
linkableUrlPattern.lastIndex = 0;

while ((match = linkableUrlPattern.exec(details)) !== null) {
// don't swallow trailing sentence punctuation into the link
var url = match[0].replace(/[.,!?;:'")\]}]+$/, '');
var start = match.index;
var end = start + url.length;

html += escapeHtml(details.slice(lastIndex, start));
var href = /^https?:\/\//i.test(url) ? url : ('https://' + url);
html += '<a href="' + escapeHtml(href) + '" data-unfurl-url="' + escapeHtml(href) + '" target="_blank" rel="noopener nofollow external" title="Opens in a new window">' +
escapeHtml(url) + '</a>';

lastIndex = end;
linkableUrlPattern.lastIndex = end;
}
html += escapeHtml(details.slice(lastIndex));
return html;
};

$.fn.truncateString = function ( str, maxLength=250 ) {
let text = str.substring(0,maxLength);
if (str.length > maxLength) {
Expand All @@ -119,4 +172,41 @@
return 0;
};

// fetch oEmbed cards for any ridewithgps links in the container.
// uses IntersectionObserver so requests only fire when a link is actually
// visible (i.e. after the user expands that event's details).
$.fn.loadUnfurls = function() {
var links = this.find('a[data-unfurl-url]:not([data-unfurl-observed])');
if (!links.length || !('IntersectionObserver' in window)) return;

links.attr('data-unfurl-observed', '1');

var observer = new IntersectionObserver(function(entries, obs) {
entries.forEach(function(entry) {
if (!entry.isIntersecting) return;
obs.unobserve(entry.target);

var link = $(entry.target);
var eventDetails = link.closest('.event-details');
// one card per event — first ridewithgps link wins
if (!eventDetails.length || eventDetails.data('unfurl-loaded')) return;
eventDetails.data('unfurl-loaded', true);

var url = link.data('unfurl-url');
$.ajax({
type: 'GET',
url: 'https://ridewithgps.com/oembed?format=json&url=' + encodeURIComponent(url),
dataType: 'json',
success: function(data) {
if (!data.html) return;
var card = $('<div class="rwgps-unfurl"></div>').html(data.html);
link.closest('p.description').after(card);
}
});
});
});

links.each(function() { observer.observe(this); });
};

} (jQuery));
4 changes: 4 additions & 0 deletions site/themes/s2b_hugo_theme/assets/js/cal/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ $(document).ready(function() {
}
value.webLink = container.getWebLink(value.weburl);
value.contactLink = container.getContactLink(value.contact);
value.linkedDetails = container.getLinkedDetails(value.details);
value.addToGoogleLink = container.getAddToGoogleLink(value);

groupedByDate[date].events.push(value);
Expand Down Expand Up @@ -137,6 +138,7 @@ $(document).ready(function() {
getEventHTML(view, function (eventHTML) {
container.append(eventHTML);
lazyLoadEventImages();
container.loadUnfurls();
$(document).off('click', '#load-more')
.on('click', '#load-more', function(e) {
// if there is a user-provided enddate, use that to set the day range (and add 1 so date range is inclusive);
Expand All @@ -150,6 +152,7 @@ $(document).ready(function() {
getEventHTML(view, function(eventHTML) {
$('#load-more').before(eventHTML);
lazyLoadEventImages();
container.loadUnfurls();
});
return false;
});
Expand All @@ -163,6 +166,7 @@ $(document).ready(function() {
}, function (eventHTML) {
container.append(eventHTML);
lazyLoadEventImages();
container.loadUnfurls();
});
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ <h3>
[[/ridelength]]
</div>

<p class="description">[[details]]</p>
<p class="description">[[& linkedDetails]]</p>

[[#email]]
<p>
Expand Down