You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
522 lines
14 KiB
522 lines
14 KiB
<!DOCTYPE html>
|
|
<meta charset=UTF-8>
|
|
<title>Tootpick</title>
|
|
|
|
<!--
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero 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 Affero General Public License for more details.
|
|
-->
|
|
|
|
<meta name=viewport content="width=device-width, initial-scale=1">
|
|
<meta name=referrer content=no-referrer>
|
|
<meta http-equiv=Content-Security-Policy content="default-src 'self' 'unsafe-inline' https:; connect-src https:; img-src https:">
|
|
<link rel=icon href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 -14 16 16'><text>🔁</text></svg>">
|
|
<style>
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
html {
|
|
height: 100%;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
body {
|
|
padding: 0;
|
|
margin: 0;
|
|
display: flex;
|
|
min-height: 100%;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
align-items: center;
|
|
background: #d9dce6;
|
|
background-size: cover;
|
|
background-position: 50% 50%;
|
|
line-height: 120%;
|
|
color: white;
|
|
font-family: sans-serif;
|
|
font-size: 15px;
|
|
}
|
|
main * {
|
|
font: inherit;
|
|
border: 0;
|
|
border-radius: 4px;
|
|
}
|
|
:focus {
|
|
outline: 1px dashed #eee;
|
|
outline-offset: 2px;
|
|
}
|
|
::placeholder {
|
|
font-style: italic;
|
|
color: #666;
|
|
}
|
|
a, summary {
|
|
color: inherit;
|
|
text-decoration: underline;
|
|
cursor: pointer;
|
|
}
|
|
a:hover, summary:hover {
|
|
filter: brightness(150%);
|
|
}
|
|
main {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .8em;
|
|
background: #444b5d;
|
|
border-radius: 4px;
|
|
padding: .8em;
|
|
max-width: 40rem;
|
|
margin-top: auto;
|
|
margin-bottom: auto;
|
|
transition: opacity 1s; /* same as JS timeout */
|
|
opacity: 1;
|
|
}
|
|
.transparent {
|
|
opacity: 0;
|
|
}
|
|
#message {
|
|
margin: 0;
|
|
background: white;
|
|
padding: .5em;
|
|
width: 100%;
|
|
background: #eee;
|
|
color: #222;
|
|
max-height: 9em;
|
|
overflow: auto;
|
|
cursor: not-allowed;
|
|
}
|
|
#message:hover {
|
|
background: #ccc;
|
|
color: #888;
|
|
}
|
|
#inputline {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .5em;
|
|
flex-wrap: wrap;
|
|
}
|
|
#inputline label {
|
|
flex: 1 0 auto;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
#inputline span#server {
|
|
white-space: nowrap;
|
|
margin-right: .5em;
|
|
}
|
|
input#instance {
|
|
padding: .2em .5em;
|
|
flex: 1 1 30em;
|
|
min-width: 10em;
|
|
}
|
|
input#instance:focus {
|
|
outline: 0;
|
|
}
|
|
button, input[type="submit"] {
|
|
background: #5455ff;
|
|
color: #fff;
|
|
padding: 7px 10px;
|
|
font-weight: 500;
|
|
font-size: 15px;
|
|
cursor: pointer;
|
|
}
|
|
button:hover, input[type="submit"]:hover {
|
|
background: #6364ff;
|
|
}
|
|
button:focus, input[type="submit"]:focus {
|
|
outline-offset: -5px;
|
|
}
|
|
#toot {
|
|
margin-left: auto;
|
|
}
|
|
#recent {
|
|
display: flex;
|
|
justify-content: space-around;
|
|
flex-wrap: wrap;
|
|
gap: .5em;
|
|
}
|
|
#recent > button {
|
|
flex: 0 1 140px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .5em;
|
|
align-items: center;
|
|
padding-top: .5em;
|
|
position: relative;
|
|
margin-top: 1em;
|
|
}
|
|
#recent > button img {
|
|
width: 120px;
|
|
aspect-ratio: 1200/630;
|
|
object-fit: cover;
|
|
}
|
|
button.delete {
|
|
background: black;
|
|
color: #df405a;
|
|
position: absolute;
|
|
padding: .2em .3em;
|
|
top: .2em;
|
|
right: .2em;
|
|
}
|
|
button.delete::after {
|
|
content: '\002716'; /* HEAVY MULTIPLICATION X */
|
|
}
|
|
button.delete:hover {
|
|
background: #df405a;
|
|
color: white;
|
|
}
|
|
footer {
|
|
color: #1a1a1a;
|
|
padding: 1em;
|
|
text-align: center;
|
|
}
|
|
#statusline {
|
|
padding: .5em;
|
|
background: #9baec8;
|
|
color: black;
|
|
}
|
|
#statusline.error {
|
|
background: #df405a;
|
|
color: black;
|
|
}
|
|
.hidden {
|
|
display: none !important;
|
|
}
|
|
footer details:not([open]) {
|
|
display: inline-block;
|
|
}
|
|
footer details:not([open])::before {
|
|
content: '\0000B7\000020'; /* MIDDLE DOT, SPACE */
|
|
}
|
|
footer summary {
|
|
display: inline-block;
|
|
}
|
|
footer details[open] {
|
|
border: 1px solid #aaa;
|
|
padding: .5em;
|
|
max-width: 40em;
|
|
margin: .5em auto;
|
|
}
|
|
h4 {
|
|
margin: .5em;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
body {
|
|
background: #191b22;
|
|
}
|
|
footer {
|
|
color: #aaa;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<script>
|
|
let message;
|
|
let notMastodon = 'That does not appear to be a Mastodon server.';
|
|
let timers = [];
|
|
|
|
function $(q) {
|
|
return document.querySelector(q);
|
|
}
|
|
|
|
function resetStyle() {
|
|
document.body.style.backgroundImage = 'none';
|
|
$('main').classList.remove('transparent');
|
|
}
|
|
|
|
function error(msg) {
|
|
let el = $('#statusline');
|
|
el.innerText = msg;
|
|
el.classList.add('error');
|
|
el.classList.remove('hidden');
|
|
}
|
|
|
|
function clearStatus() {
|
|
let el = $('#statusline');
|
|
el.classList.add('hidden');
|
|
el.classList.remove('error');
|
|
el.innerText = '';
|
|
}
|
|
|
|
function status(msg) {
|
|
let el = $('#statusline');
|
|
el.innerText = msg;
|
|
el.classList.remove('error');
|
|
el.classList.remove('hidden');
|
|
}
|
|
|
|
function setLocationGlobals() {
|
|
let hash = document.location.hash.replace(/^#/, '');
|
|
let args = {};
|
|
for (let arg of hash.split('&')) {
|
|
let i = arg.indexOf('='); // .split() can't preserve a second "="
|
|
let k = arg.substr(0, i);
|
|
let v = arg.substr(i + 1);
|
|
try { args[ decodeURIComponent(k) ] = decodeURIComponent(v); }
|
|
catch (e) { } // ignore URIError
|
|
}
|
|
message = args['text'] || "If you want a 'share with Mastodon' link on your website, have a look at Tootpick. https://tootpick.org/ #mastodon #tootpick";
|
|
if (args['plustospace'] == "yes") message = message.replace(/\+/g, " ");
|
|
|
|
$("#message").innerText = message;
|
|
}
|
|
|
|
function getRecent() {
|
|
try { return JSON.parse(localStorage.getItem('recent')) || []; }
|
|
catch (e) { return []; }
|
|
}
|
|
|
|
function setRecent(obj) {
|
|
// Deduplicate
|
|
let seen = new Set();
|
|
obj = obj.filter(x => {
|
|
let key = x['domain'];
|
|
return seen.has(key) ? false : seen.add(key);
|
|
});
|
|
|
|
// The box will show 2, 3, or 4 per row, limit of 12 ensures
|
|
// truncating at a whole row.
|
|
if (obj.length > 12) obj.length = 12;
|
|
|
|
localStorage.setItem('recent', JSON.stringify(obj));
|
|
}
|
|
|
|
function populateRecent() {
|
|
let recent = getRecent();
|
|
|
|
if (!recent.length) {
|
|
$("#recent").classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
$('#recent').innerHTML = '';
|
|
|
|
for (let item of getRecent()) {
|
|
let div = document.createElement('button');
|
|
div.dataset['instance'] = JSON.stringify(item);
|
|
let img = document.createElement('img');
|
|
img.src = item['thumbnail'];
|
|
img.setAttribute('alt', ''); // intentional empty string
|
|
let del = document.createElement('button');
|
|
del.classList.add('delete');
|
|
del.setAttribute('title', 'Forget ' + item['domain']);
|
|
let text = document.createElement('div');
|
|
text.innerText = "Continue with\n" + item['domain'];
|
|
|
|
div.setAttribute('title', item['title']);
|
|
div.appendChild(del);
|
|
div.appendChild(img);
|
|
div.appendChild(text);
|
|
$('#recent').appendChild(div);
|
|
}
|
|
}
|
|
|
|
function tootRedirect(domain, message) {
|
|
document.location = `https://${domain}/share?text=${ encodeURIComponent(message) }`;
|
|
}
|
|
|
|
function tryNewInstance(domain, message) {
|
|
status(`Connecting to ${domain}...`);
|
|
|
|
fetch(`https://${domain}/api/v1/instance`)
|
|
.then((resp) => resp.json())
|
|
.then(function (data) {
|
|
if (!data['version'] || data['version'].match(/compatible/i)) {
|
|
error(notMastodon);
|
|
return;
|
|
}
|
|
let thumbnail = data['thumbnail'];
|
|
let title = data['title'];
|
|
|
|
if ($('#remember').checked) {
|
|
let recent = getRecent();
|
|
recent.unshift({ domain, thumbnail, title });
|
|
setRecent(recent);
|
|
}
|
|
|
|
let redirect = () => tootRedirect(domain, message);
|
|
// backgroundImage doesn't trigger events; dummy image will load
|
|
// while background is also loaded, browser uses single connection
|
|
// for both.
|
|
let dummy = new Image();
|
|
dummy.addEventListener('error', redirect); // no point in waiting
|
|
dummy.addEventListener('load', () => {
|
|
timers.push(setTimeout(redirect, 1000));
|
|
$("main").classList.add('transparent');
|
|
// backgroundImage not set here but loading in parallel,
|
|
// so visitor can see loading happening
|
|
});
|
|
dummy.src = thumbnail;
|
|
document.body.style.backgroundImage = `url(${thumbnail})`;
|
|
timers.push(setTimeout(redirect, 3500)); // fallback;
|
|
})
|
|
.catch((err) => {
|
|
// Can't do real webfinger, because that needs an acct: parameter.
|
|
// But we can at least do an opportunistic attempt to support
|
|
// *some* servers where WEB_DOMAIN != LOCAL_DOMAIN. This only
|
|
// works if the redirect and the target both allow CORS.
|
|
fetch(`https://${domain}/.well-known/webfinger`)
|
|
.then((resp) => {
|
|
if (resp.redirected) {
|
|
let found = resp.url.match(/^https:\/\/([^\/]+)\/\.well-known\/webfinger/);
|
|
if (found) tryNewInstance(found[1], message);
|
|
else throw(new Error('Weird webfinger'));
|
|
} else {
|
|
throw(new Error('No webfinger'));
|
|
}
|
|
})
|
|
.catch((err) => error(notMastodon));
|
|
});
|
|
|
|
}
|
|
|
|
window.addEventListener('hashchange', setLocationGlobals);
|
|
|
|
window.addEventListener('pageshow', () => {
|
|
while (timers.length) clearTimeout(timers.shift());
|
|
resetStyle();
|
|
setLocationGlobals();
|
|
populateRecent();
|
|
clearStatus();
|
|
});
|
|
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
$("#instance").addEventListener('keydown', clearStatus);
|
|
$("#instance").addEventListener('click', clearStatus);
|
|
|
|
$("#message").addEventListener('click', () => {
|
|
status($("#message").getAttribute('title'))
|
|
});
|
|
|
|
$("form").addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
|
|
// domain is expected, but user might enter URL
|
|
let instance = $("#instance").value.trim();
|
|
|
|
if (instance == '') {
|
|
error($("#instance").closest("label").getAttribute("title"));
|
|
return;
|
|
}
|
|
|
|
// remove trailing slash and username, if present
|
|
instance = instance.replace(/\/+(?:@.*)?$/, "");
|
|
|
|
// newbies may write https:foo or https//foo too.
|
|
instance = instance.replace(/^https?\W+/, "");
|
|
|
|
// Deal with user@host and @user@host, and for really advanced
|
|
// newbies, acct:user@host because why not :)
|
|
instance = instance.replace(/^(?:acct:)?@?[^@]+@/, "");
|
|
|
|
// Some advanced users may enter an FQDN with a trailing dot, but
|
|
// that needs to be normalized because the domain is used as a
|
|
// lookup key. Speaking of normalization, let's lowercase.
|
|
instance = instance.replace(/\.$/, "").toLowerCase();
|
|
|
|
// Check whether the input is a valid domain name, in order to
|
|
// prevent leaking e.g. pasted clipboard text (passwords, credit
|
|
// card numbers, love letters, ...) via DNS. Can't prevent
|
|
// every mistake, but let's catch at least the obvious cases.
|
|
if (! instance.match(/^(?:(?:xn--)?[a-z0-9]+[-.])*[a-z0-9]+\.[a-z]{2,}$/)) {
|
|
// TODO: support user readable IDN and %-encoded IDN (both uncommon)
|
|
error('Not a valid internet domain name.');
|
|
return;
|
|
}
|
|
|
|
$("#instance").value = instance;
|
|
|
|
tryNewInstance(instance, message);
|
|
});
|
|
|
|
$("#recent").addEventListener('click', (event) => {
|
|
let button = event.target.closest('button');
|
|
if (!button) return;
|
|
|
|
|
|
if (button.classList.contains('delete')) {
|
|
let instance = JSON.parse( button.parentElement.dataset['instance'] );
|
|
|
|
setRecent(getRecent().filter(
|
|
x => x['domain'] != instance['domain']
|
|
));
|
|
|
|
button.parentElement.remove();
|
|
} else {
|
|
let instance = JSON.parse( button.dataset['instance'] );
|
|
document.body.style.backgroundImage = `url(${ instance['thumbnail'] })`;
|
|
|
|
let recent = getRecent();
|
|
recent.unshift(instance);
|
|
setRecent(recent);
|
|
|
|
$("main").classList.add('transparent');
|
|
timers.push(setTimeout(() => tootRedirect(instance['domain'], message), 1000));
|
|
}
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<main data-nosnippet>
|
|
<noscript>
|
|
<div class=error>
|
|
Please enable JavaScript to use this service. This page requires
|
|
JavaScript to keep all the data in your browser. The alternative would
|
|
be to send message contents to the server, which would be the worse
|
|
decision privacy-wise.
|
|
</div>
|
|
</noscript>
|
|
<div>
|
|
Pick your Mastodon server to toot this message:
|
|
</div>
|
|
<blockquote id=message
|
|
title="You can edit the text on the next page, on your own Mastodon server.">
|
|
</blockquote>
|
|
<form id=inputline>
|
|
<label title="Enter the domain name of your Mastodon server (sometimes called 'instance')">
|
|
<span id=server>Server:</span>
|
|
<input id=instance placeholder="social.example.org">
|
|
</label>
|
|
<label>
|
|
<input type=checkbox id=remember checked> Remember
|
|
</label>
|
|
<input type=submit id=toot value="Continue">
|
|
</form>
|
|
<div id=statusline class="hidden">
|
|
</div>
|
|
<div id=recent>
|
|
</div>
|
|
</main>
|
|
<footer>
|
|
<a href="https://github.com/Juerd/tootpick">Tootpick</a> is a
|
|
privacy-preserving instance picker for sharing links from
|
|
news sites, blogs, etc.<br>
|
|
<a href="https://github.com/Juerd/tootpick">Add Tootpick to your website</a>
|
|
<details data-nosnippet>
|
|
<summary>Legal notices</summary>
|
|
<h4>License</h4>
|
|
This program is free software: you can redistribute it and/or modify it
|
|
under the terms of the <a href="https://www.gnu.org/licenses/agpl-3.0.html">
|
|
GNU Affero General Public License</a> as published by the Free Software
|
|
Foundation, either version 3 of the License, or (at your option) any
|
|
later version.
|
|
<h4>Disclaimer</h4>
|
|
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 Affero General Public License for more details.
|
|
<h4>Privacy poliy</h4>
|
|
Tootpick does not collect or store any of your data except on your own
|
|
computer. The server that hosts Tootpick, and the Mastodon servers you
|
|
enter, will probably store your IP address and User-Agent (browser)
|
|
identification in a web server log file.
|
|
</details>
|
|
</footer>
|
|
|