Migrate to Astro (#30)

This commit is contained in:
Nikita Karamov 2023-03-18 03:20:22 +01:00
commit fa583ac74f
No known key found for this signature in database
GPG Key ID: 41D6F71EE78E77CD
31 changed files with 3333 additions and 1815 deletions

View File

@ -1,15 +1,28 @@
{
"root": true,
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:unicorn/recommended",
"plugin:astro/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint"],
"env": {
"browser": true
},
"extends": ["eslint:recommended", "plugin:unicorn/recommended", "prettier"],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"overrides": [
{
"files": ["api/*.js"],
"files": ["*.astro"],
"parser": "astro-eslint-parser",
"parserOptions": {
"parser": "@typescript-eslint/parser",
"extraFileExtensions": [".astro"]
}
},
{
"files": ["api/*.js", "astro.config.ts"],
"env": {
"node": true,
"browser": false
@ -17,6 +30,12 @@
"rules": {
"unicorn/prefer-node-protocol": 0
}
},
{
"files": ["src/env.d.ts"],
"rules": {
"unicorn/prevent-abbreviations": 0
}
}
]
}

View File

@ -16,17 +16,27 @@ repos:
- id: prettier
additional_dependencies:
- prettier@2
- prettier-plugin-astro
- repo: https://github.com/pre-commit/mirrors-eslint
rev: "v8.43.0"
hooks:
- id: eslint
files: \.([jt]s|astro)$ # *.js, *.ts and *.astro
types: [file]
additional_dependencies:
- "@typescript-eslint/eslint-plugin"
- "@typescript-eslint/parser"
- astro-eslint-parser
- eslint
- eslint-config-prettier
- eslint-plugin-astro
- eslint-plugin-unicorn
- prettier@2
- prettier-plugin-astro
- repo: https://github.com/awebdeveloper/pre-commit-stylelint
rev: "0.0.2"
hooks:
- id: stylelint
additional_dependencies: ["stylelint", "stylelint-config-standard-scss"]
additional_dependencies:
- stylelint
- stylelint-config-standard-scss

View File

@ -13,15 +13,27 @@ as to indicate that sharing to other federated networks is now possible.
### ⚠️ BREAKING CHANGES
- **new API endpoint path**: ~~`/api/toot`~~`/api/share`
- **new API endpoint port**: ~~`:8000`~~`:8080`
- API endpoint **is now ESM-based** instead of CommonJS
- **new static path**: ~~`./public`~~`./dist`
Share₂Fedi is now an [Astro](https://astro.build/) site. The migration allowed
us to have a performant service that is easily hostable on both serverless
platforms, like Vercel or Netlify, as well as locally. Setting the project up
now takes seconds! This comes with changes, though:
- **static files aren't built any more**, but generated server-side
- **new output directory**: ~~`public`~~`dist`
- this also means that `public` **is not ignored any more**
Some changes came with the name change:
- **changed API endpoint path**: ~~`/api/toot`~~`/api/share`
### Added
- support for Pleroma
- support for GNU Social
- remembering of multiple Fediverse instances
- new API endpoints
- `/api/instances` will return the list of popular instances
- `/api/detect/[host]` will detect the Fediverse project used by a host
- when developing, the API endpoint can now be tested locally thanks to
[`vite-plugin-node`](https://github.com/axe-me/vite-plugin-node)
- a privacy policy describing what data is being processed and stored
@ -34,9 +46,9 @@ as to indicate that sharing to other federated networks is now possible.
- new logo
- new design
- repository moved back to GitHub
- s2f is now being built with Vite
- `@vitejs/plugin-legacy` is used, which allows JS work on old browsers, which
comes, with big bundle sizes. Modern browsers still get a small bundle.
- s2f is now being built with Astro
- Share₂Fedi is now 100% server-side rendered. You don't have to host any
static files, all you need is to run the Node server.
## [2.4.5] - 2023-06-17

View File

@ -24,12 +24,18 @@ appended if used later—handy!
## Hosting
### One-click Vercel deploy
### Vercel, Netlify, Cloudflare Pages
**Share₂Fedi** is designed to run on [Vercel](https://vercel.com/). To deploy it
yourself (it's free!), you can use the following button:
**Share₂Fedi** was designed to run on [Vercel](https://vercel.com/), but you can
also run it on [Netlify](https://www.netlify.com/) or
[Cloudflare Pages](https://pages.cloudflare.com/). To deploy it yourself (it's
free!), you can use the following buttons:
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fkytta%2Fshare2fedi)
[![Deploy to Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fkytta%2Fshare2fedi)
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/kytta/share2fedi)
To deploy to Cloudflare Pages, fork the repo and
[follow the instructions](https://docs.astro.build/en/guides/deploy/cloudflare/#how-to-deploy-a-site-with-git).
### Host it yourself
@ -37,67 +43,64 @@ Self-hosting **Share₂Fedi** outside of Vercel requires some extra setup:
**Prerequisites:** modern Node.js (v16 or later), `pnpm`.
1. Install the dependencies:
1. Install dependencies.
```sh
pnpm install
```
2. Build the static files:
2. Build.
```sh
pnpm build
```
3. Run the backend server for the form:
3. Run server.
> By default, this will only listen on localhost port 3000. To enable
> listening on a ceratin hostand/or port, set the `HOST` and `PORT`
> environment variables, respectively.
```sh
node api/share.js
node dist/server/entry.mjs
```
alternatively, if you want to run the process in the background:
```sh
pm2 start api/share.js --watch --ignore-watch="node_modules"
pm2 start dist/server/entry.mjs --watch --ignore-watch="node_modules"
```
> You can find a summary for pm2 at:
> https://pm2.keymetrics.io/docs/usage/quick-start/
4. Set up a web server
> More information about self-hosting an Astro website with Node:
> https://docs.astro.build/en/guides/integrations-guide/node/#standalone
Basically, you need to run a server that would proxy the requests to
`/api/share`. to the Node.js server you started. Here's how to achieve this
in various HTTP servers:
4. Set up a reverse proxy.
Basically, you need to run a reverse proxy that would redirect all incoming
requests to `localhost:3000`. Here's how to achieve this in various HTTP
servers:
1. Apache
```apacheconf
DocumentRoot "<PATH_TO_SHARE2FEDI>/dist"
ProxyPass "/api/share" "http://localhost:8080/"
ProxyPass "/" "http://localhost:3000/"
```
2. Nginx
```nginxconf
root <PATH_TO_SHARE2FEDI>/dist;
index.html;
location /api/share {
proxy_pass http://localhost:8080/;
location / {
proxy_pass http://localhost:3000/;
}
```
3. Caddy
```caddy
root * <PATH_TO_SHARE2FEDI>/dist;
try_files index.html
handle_path /api/share {
reverse_proxy localhost:8080
}
reverse_proxy :3000
```
## See also

View File

@ -1,102 +0,0 @@
/*!
share2fedi - Instance-agnostic share page for the Fediverse.
Copyright (C) 2020-2023 Nikita Karamov <me@kytta.dev>
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.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
SPDX-License-Identifier: AGPL-3.0-or-later
*/
import http from "http";
import https from "https";
const pathsMap = {
mastodon: {
checkUrl: "/api/v1/instance/rules",
postUrl: "share",
textParam: "text",
},
gnuSocial: {
checkUrl: "/api/statusnet/version.xml",
postUrl: "/notice/new",
textParam: "status_textarea",
},
pleroma: {
checkUrl: "/api/v1/pleroma/federation_status",
postUrl: "share",
textParam: "message",
},
};
const queryUrl = (url, service) => {
return new Promise((resolve, reject) => {
const get = url.protocol === "https:" ? https.get : http.get;
get(url, ({ statusCode }) => {
if (statusCode === 200) {
console.debug(url.href, "is", service);
resolve(service);
} else {
reject(url);
}
}).on("error", (error) => {
reject(error);
});
});
};
const detectService = async (instanceURL) => {
const checkPromises = Object.entries(pathsMap).map(
([service, { checkUrl }]) =>
queryUrl(new URL(checkUrl, instanceURL), service),
);
return await Promise.any(checkPromises);
};
const requestListener = async (request, response) => {
if (request.method !== "POST") {
response.writeHead(405).end();
return;
}
let data = "";
request.on("data", (chunk) => {
data += chunk.toString();
});
request.on("end", () => {
const requestBody = new URLSearchParams(data);
const postText = requestBody.get("text") || "";
const instanceURL =
requestBody.get("instance") || "https://mastodon.social/";
detectService(instanceURL)
.then((service) => {
const publishUrl = new URL(pathsMap[service].postUrl, instanceURL);
publishUrl.search = new URLSearchParams([
[pathsMap[service].textParam, postText],
]);
response.writeHead(303, { Location: publishUrl.toString() }).end();
})
.catch((error) => {
response.writeHead(400).end(error);
});
});
};
if (!import.meta.env || import.meta.env.PROD) {
http.createServer(requestListener).listen(8080);
}
export const viteNodeApp = requestListener;

View File

@ -1,3 +1,4 @@
<!-- © 2022 Nikita Karamov. Licensed under CC-BY 4.0 -->
<svg viewBox="0 0 64 64" width="512" height="512" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
<defs>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,3 +1,4 @@
<!-- © 2022 Nikita Karamov. Licensed under CC-BY 4.0 -->
<svg viewBox="0 0 64 64" width="512" height="512" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
<defs>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -1,3 +1,4 @@
<!-- © 2022 Nikita Karamov. Licensed under CC-BY 4.0 -->
<svg viewBox="0 0 260 80" width="520" height="160" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
<defs>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

29
astro.config.ts Normal file
View File

@ -0,0 +1,29 @@
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
import netlify from "@astrojs/netlify/functions";
import node from "@astrojs/node";
import vercel from "@astrojs/vercel/serverless";
let astroAdapter;
if (process.env.CF_PAGES) {
console.debug("Using Cloudflare adapter");
astroAdapter = cloudflare();
} else if (process.env.VERCEL) {
console.debug("Using Vercel adapter");
astroAdapter = vercel();
} else if (process.env.NETLIFY) {
console.debug("Using Netlify adapter");
astroAdapter = netlify();
} else {
console.debug("Using Node.js adapter");
astroAdapter = node({
mode: "standalone",
});
}
export default defineConfig({
site: "https://s2f.kytta.dev",
adapter: astroAdapter,
output: "server",
});

View File

@ -1,59 +0,0 @@
/*!
* @source: https://github.com/kytta/share2fedi/blob/main/lib/count.js
*
* @licstart The following is the entire license notice for the
* JavaScript code in this page.
*
* share2fedi - Instance-agnostic share page for the Fediverse.
* Copyright (C) 2023 Nikita Karamov <me@kytta.dev>
*
* 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.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* @licend The above is the entire license notice
* for the JavaScript code in this page.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
// This is the analytics code for Share₂Fedi. It just sends a beacon
// to GoatCounter with hardcoded path. This is way more lightweight, performant
// and privacy-friendly than the default GC script.
if (
window.location.host === "s2f.kytta.dev" ||
window.location.host === "share2fedi.kytta.dev"
) {
// eslint-disable-next-line unicorn/prefer-top-level-await
fetch("//gc.zgo.at/", { method: "HEAD" }).then((result) => {
// Check if the default GC URL resolves
// This allows us to not track people with ad blockers
if (!result.ok) {
return;
}
const screen = encodeURIComponent(
[
window.screen.width,
window.screen.height,
window.devicePixelRatio || 1,
].join(","),
);
const random = encodeURIComponent(Math.random().toString(36).slice(2));
navigator.sendBeacon(
`https://share2fedi.goatcounter.com/count?p=%2F&s=${screen}&b=0&rnd=${random}`,
);
});
}

View File

@ -1,142 +0,0 @@
/*!
* @source: https://github.com/kytta/share2fedi/blob/main/lib/main.js
*
* @licstart The following is the entire license notice for the
* JavaScript code in this page.
*
* share2fedi - Instance-agnostic share page for the Fediverse.
* Copyright (C) 2020-2023 Nikita Karamov <me@kytta.dev>
*
* 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.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* @licend The above is the entire license notice
* for the JavaScript code in this page.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import "./scss/style.scss";
const INSTANCE_LIST_URL = "https://api.joinmastodon.org/servers";
const LOCAL_STORAGE_KEY = "recentInstances";
const RECENT_INSTANCES_SIZE = 5;
const $form = document.querySelector("#js-s2f-form");
const $instance = document.querySelector("#instance");
const $instanceDatalist = document.querySelector("#instanceDatalist");
/**
* Adds missing "https://" and ending slash to the URL
*
* @param {string} url URL to normalize
* @return {string} normalized URL
*/
function normalizeUrl(url) {
if (!url.includes("http://") && !url.includes("https://")) {
url = "https://" + url;
}
if (url.at(-1) !== "/") {
url = url + "/";
}
return url;
}
function onLoadInstancesError() {
console.error("Couldn't load instance list");
}
function onLoadInstancesSuccess() {
if (this.status >= 400) {
return onLoadInstancesError();
}
const currentInstance = $instance.value;
const instanceDomains = JSON.parse(this.responseText).map(
(index) => index.domain,
);
if (currentInstance && !instanceDomains.includes(currentInstance)) {
instanceDomains.push(currentInstance);
}
instanceDomains.sort();
for (const instanceDomain of instanceDomains) {
const $option = document.createElement("option");
$option.value = normalizeUrl(instanceDomain);
$instanceDatalist.append($option);
}
}
function loadInstances() {
if ($instanceDatalist.children.length === 0) {
const request = new XMLHttpRequest();
request.addEventListener("load", onLoadInstancesSuccess);
request.addEventListener("error", onLoadInstancesError);
request.open("GET", INSTANCE_LIST_URL);
request.send();
}
}
function getRecentInstances() {
const storedValue = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (!storedValue) return [];
return JSON.parse(storedValue);
}
function rememberInstance(instance) {
const recentInstances = getRecentInstances();
const index = recentInstances.indexOf(instance);
if (index >= 0) {
recentInstances.splice(index, 1);
}
recentInstances.unshift(instance);
recentInstances.length = RECENT_INSTANCES_SIZE;
window.localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify(recentInstances),
);
}
function onFormSubmit(event) {
const formData = new FormData(event.target);
if (formData.get("remember")) {
rememberInstance(formData.get("instance"));
}
return true;
}
let prefillInstance = getRecentInstances()[0];
const URLParameters = window.location.search.slice(1).split("&");
for (const URLParameter of URLParameters) {
const URLParameterPair = URLParameter.split("=");
if (URLParameterPair[0] === "text") {
document.querySelector("#text").value = decodeURIComponent(
URLParameterPair[1],
);
} else if (URLParameterPair[0] === "instance") {
prefillInstance = decodeURIComponent(URLParameterPair[1]);
}
}
if (prefillInstance != undefined) {
$instance.value = normalizeUrl(prefillInstance);
}
$instance.addEventListener("focus", loadInstances);
$form.addEventListener("submit", onFormSubmit);

View File

@ -12,33 +12,41 @@
"private": true,
"type": "module",
"scripts": {
"build": "vite build",
"dev": "vite",
"fmt": "prettier --write .",
"build": "astro build",
"dev": "astro dev",
"fmt": "prettier --write --plugin-search-dir=. .",
"lint": "prettier --check . && eslint . && stylelint '**/*.scss'",
"preview": "vite preview",
"preview": "astro preview",
"start": "astro dev",
"test": "pnpm run lint"
},
"browserslist": "cover 95%, last 2 versions, Firefox ESR, not dead",
"dependencies": {
"@astrojs/cloudflare": "^6.2.1",
"@astrojs/netlify": "^2.2.0",
"@astrojs/node": "^5.1.0",
"@astrojs/vercel": "^3.2.1",
"astro": "^2.1.3"
},
"devDependencies": {
"@vitejs/plugin-legacy": "^4.0.2",
"@typescript-eslint/eslint-plugin": "^5.55.0",
"@typescript-eslint/parser": "^5.55.0",
"autoprefixer": "^10.4.14",
"browserslist": "^4.21.5",
"eslint": "^8.36.0",
"eslint-config-prettier": "^8.7.0",
"eslint-plugin-astro": "^0.25.0",
"eslint-plugin-unicorn": "^46.0.0",
"postcss": "^8.4.21",
"postcss-csso": "^6.0.1",
"prettier": "^2.8.4",
"prettier-plugin-astro": "^0.8.0",
"sass": "^1.59.3",
"sharp": "^0.31.3",
"stylelint": "^15.2.0",
"stylelint-config-standard-scss": "^7.0.1",
"svgo": "^3.0.2",
"terser": "^5.16.6",
"vite": "^4.2.0",
"vite-plugin-image-optimizer": "^1.1.2",
"vite-plugin-node": "^3.0.2"
"typescript": "^5.0.2"
},
"postcss": {
"map": true,

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 149 KiB

View File

@ -1,25 +1 @@
<svg viewBox="0 0 64 64" width="512" height="512" xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1">
<defs>
<filter id="blur">
<feGaussianBlur stdDeviation="15"></feGaussianBlur>
</filter>
<clipPath id="pentagon">
<path d="m38.802 1.632 23.656 29.212-20.473 31.524-36.307-9.729-1.968-37.537z"></path>
</clipPath>
</defs>
<g clip-path="url(#pentagon)">
<g filter="url(#blur)">
<circle cx="0" cy="10" fill="#F44336" r="39"></circle>
<circle cx="45" cy="-5" fill="#FFEB3B" r="39"></circle>
<circle cx="70" cy="25" fill="#4CAF50" r="39"></circle>
<circle cx="50" cy="65" fill="#03A9F4" r="39"></circle>
<circle cx="5" cy="70" fill="#673AB7" r="39"></circle>
<circle cx="50" cy="31.5" fill="#4CAF50" r="12"></circle>
<circle cx="37.5" cy="50.5" fill="#03A9F4" r="12"></circle>
<circle cx="15.5" cy="44.5" fill="#673AB7" r="12"></circle>
<circle cx="14" cy="22" fill="#F44336" r="12"></circle>
<circle cx="36" cy="14" fill="#FFEB3B" r="12"></circle>
</g>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 64 64"><defs><clipPath id="a"><path d="m38.802 1.632 23.656 29.212-20.473 31.524-36.307-9.729L3.71 15.102z"/></clipPath><filter id="b"><feGaussianBlur stdDeviation="15"/></filter></defs><g clip-path="url(#a)" filter="url(#b)"><circle cy="10" r="39" fill="#F44336"/><circle cx="45" cy="-5" r="39" fill="#FFEB3B"/><circle cx="70" cy="25" r="39" fill="#4CAF50"/><circle cx="50" cy="65" r="39" fill="#03A9F4"/><circle cx="5" cy="70" r="39" fill="#673AB7"/><circle cx="50" cy="31.5" r="12" fill="#4CAF50"/><circle cx="37.5" cy="50.5" r="12" fill="#03A9F4"/><circle cx="15.5" cy="44.5" r="12" fill="#673AB7"/><circle cx="14" cy="22" r="12" fill="#F44336"/><circle cx="36" cy="14" r="12" fill="#FFEB3B"/></g></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 786 B

View File

@ -1,6 +1,9 @@
#!/bin/bash
# This script converts raw SVG icons to favicons according to the article:
# https://evilmartians.com/chronicles/how-to-favicon-in-2021-six-files-that-fit-most-needs
#
# © 2023 Nikita Karamov
# Licensed under AGPL v3 or later
set -euo pipefail
@ -9,17 +12,9 @@ if ! type "magick"; then
exit 1
fi
echo "[1/4] Making 'favicon.ico' for legacy browsers..."
magick convert -background none assets/pentagon.svg -alpha Set -define icon:auto-resize="32,16" -channel RGBA -depth 8 public/favicon.ico
node script/icons.js
echo "[2/4] Making 'icon.svg' for modern browsers..."
cp -f assets/pentagon.svg public/icon.svg
echo "[3/4] Making 'apple-touch-icon.png'..."
magick convert assets/s2f.svg -resize 140x140 -background white -gravity center -extent 180x180 public/apple-touch-icon.png
echo "[4/4] Making 'icon-*.png' icons for PWAs..."
magick convert -background none assets/s2f.svg -alpha Set -resize 192x192 public/icon-192.png
magick convert -background none assets/s2f.svg -alpha Set -resize 512x512 public/icon-512.png
magick convert public/favicon-32.png public/favicon-16.png public/favicon.ico
rm public/favicon-32.png public/favicon-16.png
echo "Done."

44
script/icons.js Normal file
View File

@ -0,0 +1,44 @@
/*!
* © 2023 Nikita Karamov
* Licensed under AGPL v3 or later
*/
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import sharp from "sharp";
import { optimize } from "svgo";
const pentagon = join(".", "assets", "pentagon.svg");
const smallLogo = join(".", "assets", "s2f.svg");
const outputDirectory = join(".", "public");
try {
await Promise.all([
sharp(pentagon).resize(32).toFile(join(outputDirectory, "favicon-32.png")),
sharp(pentagon).resize(16).toFile(join(outputDirectory, "favicon-16.png")),
sharp(smallLogo)
.resize(140)
.extend({
top: 20,
bottom: 20,
left: 20,
right: 20,
background: "#000",
})
.flatten({ background: "#000" })
.toFile(join(outputDirectory, "apple-touch-icon.png")),
sharp(smallLogo).resize(192).toFile(join(outputDirectory, "icon-192.png")),
sharp(smallLogo).resize(512).toFile(join(outputDirectory, "icon-512.png")),
]);
} catch (error) {
console.error(error);
}
writeFileSync(
join(outputDirectory, "icon.svg"),
optimize(readFileSync(pentagon), {
path: pentagon,
multipass: true,
}).data,
);

View File

@ -0,0 +1,144 @@
---
/*!
* © 2023 Nikita Karamov
* Licensed under AGPL v3 or later
*/
let instances;
try {
const response = await fetch(new URL("/api/instances", Astro.url));
instances = await response.json();
} catch (error) {
console.error("Couln't fetch instances:", error);
instances = [];
}
const { prefilledInstance } = Astro.props;
---
<datalist id="instanceDatalist">
{instances.map((instance) => <option value={instance} />)}
</datalist>
<label id="s2f-instanceContainer">
Mastodon, Pleroma, or GNU Social instance
<div class="instance-input">
<span id="https-label">https://</span>
<input
type="text"
name="instance"
id="instance"
placeholder="mastodon.social"
list="instanceDatalist"
required
aria-describedby="https-label"
value={prefilledInstance}
/>
</div>
</label>
<label for="remember">
<input
type="checkbox"
id="remember"
name="remember"
/>
Remember my instance on this device
</label>
<style lang="scss">
:global(.previously-used) {
color: var(--s2f-accent-color-contrast);
cursor: pointer;
text-decoration: 1px solid underline currentColor;
}
.instance-input {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: stretch;
width: 100%;
margin-bottom: 1rem;
span {
display: flex;
align-items: center;
padding: 0.5rem;
font-size: 1rem;
}
input[type="text"] {
position: relative;
flex: 1 1 auto;
width: 1%;
}
}
</style>
<script>
import { extractHost, normalizeURL } from "../util";
const LOCAL_STORAGE_KEY = "recentInstances";
const RECENT_INSTANCES_SIZE = 5;
const $form = document.querySelector("#js-s2f-form");
const $instanceContainer = document.querySelector("#s2f-instanceContainer");
const $instance: HTMLInputElement = document.querySelector("#instance");
const getSavedInstances = (): Array<string> => {
const storageValue = window.localStorage.getItem(LOCAL_STORAGE_KEY);
if (!storageValue) {
return [];
}
return JSON.parse(storageValue);
};
const savedInstances = getSavedInstances();
if (savedInstances.length > 0) {
$instanceContainer.append(
"Previously used: ",
...savedInstances
.flatMap((instance, index) => {
if (!instance) {
return [];
}
const host = extractHost(instance);
if (index == 0 && !$instance.value) {
$instance.value = host;
}
const element = document.createElement("span");
element.textContent = host;
element.classList.add("previously-used");
element.addEventListener("click", () => {
$instance.value = host;
});
return [element, ", "];
})
.slice(0, -1),
);
}
$form.addEventListener("submit", (event) => {
const formData = new FormData(event.target as HTMLFormElement);
if (formData.get("remember")) {
const instance = normalizeURL(formData.get("instance") as string);
const index = savedInstances.indexOf(instance);
if (index >= 0) {
savedInstances.splice(index, 1);
}
savedInstances.unshift(instance);
savedInstances.length = RECENT_INSTANCES_SIZE;
window.localStorage.setItem(
LOCAL_STORAGE_KEY,
JSON.stringify(savedInstances),
);
}
return true;
});
</script>

35
src/count.js Normal file
View File

@ -0,0 +1,35 @@
/*!
* © 2022 Nikita Karamov
* Licensed under AGPL v3 or later
*/
// This is the analytics code for Share₂Fedi. It just sends a beacon
// to GoatCounter with hardcoded path. This is way more lightweight, performant
// and privacy-friendly than the default GC script.
if (
window.location.host === "s2f.kytta.dev" ||
window.location.host === "share2fedi.kytta.dev"
) {
// eslint-disable-next-line unicorn/prefer-top-level-await
fetch("//gc.zgo.at/", { method: "HEAD" }).then((result) => {
// Check if the default GC URL resolves
// This allows us to not track people with ad blockers
if (!result.ok) {
return;
}
const screen = encodeURIComponent(
[
window.screen.width,
window.screen.height,
window.devicePixelRatio || 1,
].join(","),
);
const random = encodeURIComponent(Math.random().toString(36).slice(2));
navigator.sendBeacon(
`https://share2fedi.goatcounter.com/count?p=%2F&s=${screen}&b=0&rnd=${random}`,
);
});
}

1
src/env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="astro/client" />

View File

@ -0,0 +1,83 @@
/*!
* © 2023 Nikita Karamov
* Licensed under AGPL v3 or later
*/
import { APIRoute } from "astro";
import { normalizeURL } from "../../../util";
const PROJECTS = {
mastodon: {
checkUrl: "/api/v1/instance/rules",
publishEndpoint: "share",
params: {
text: "text",
},
},
gnuSocial: {
checkUrl: "/api/statusnet/version.xml",
publishEndpoint: "/notice/new",
params: {
text: "status_textarea",
},
},
pleroma: {
checkUrl: "/api/v1/pleroma/federation_status",
publishEndpoint: "share",
params: {
text: "message",
},
},
};
const checkProjectUrl = (
urlToCheck: URL,
projectId: string,
): Promise<string> => {
return new Promise((resolve, reject) => {
fetch(urlToCheck)
.then((response) => {
if (response.ok) {
resolve(projectId);
} else {
reject(urlToCheck);
}
})
.catch((error) => {
reject(error.toString());
});
});
};
export const get: APIRoute = async ({ params }) => {
const host = params.host;
const promises = Object.entries(PROJECTS).map(([service, { checkUrl }]) =>
checkProjectUrl(new URL(checkUrl, normalizeURL(host)), service),
);
try {
const project = await Promise.any(promises);
return new Response(
JSON.stringify({
host,
project,
publishEndpoint: PROJECTS[project].publishEndpoint,
params: PROJECTS[project].params,
}),
{
status: 200,
headers: {
"Cache-Control": "s-maxage=86400, max-age=86400, public",
"Content-Type": "application/json",
},
},
);
} catch {
return new Response(JSON.stringify({ error: "Couldn't detect instance" }), {
status: 400,
headers: {
"Content-Type": "application/json",
},
});
}
};

View File

@ -0,0 +1,30 @@
/*!
* © 2023 Nikita Karamov
* Licensed under AGPL v3 or later
*/
import { APIRoute } from "astro";
export const get: APIRoute = async () => {
try {
const response = await fetch("https://api.joinmastodon.org/servers");
const instances = await response.json();
return new Response(
JSON.stringify(instances.map((instance) => instance.domain)),
{
headers: {
"Cache-Control": "s-maxage=86400, max-age=86400, public",
"Content-Type": "application/json",
},
},
);
} catch (error) {
console.error("Could not fetch instances:", error);
return new Response(JSON.stringify([]), {
headers: {
"Content-Type": "application/json",
},
});
}
};

29
src/pages/api/share.ts Normal file
View File

@ -0,0 +1,29 @@
/*!
* © 2023 Nikita Karamov
* Licensed under AGPL v3 or later
*/
import { APIRoute } from "astro";
export const post: APIRoute = async ({ redirect, request, url }) => {
const formData = await request.formData();
const text = (formData.get("text") as string) || "";
const instanceHost =
(formData.get("instance") as string) || "mastodon.social";
try {
const response = await fetch(new URL(`/api/detect/${instanceHost}`, url));
const { host, publishEndpoint, params } = await response.json();
const publishUrl = new URL(publishEndpoint, `https://${host}/`);
publishUrl.search = new URLSearchParams([[params.text, text]]).toString();
return redirect(publishUrl.toString(), 303);
} catch {
return new Response(JSON.stringify({ error: "Couldn't detect instance" }), {
status: 400,
headers: {
"Content-Type": "application/json",
},
});
}
};

View File

@ -1,29 +1,20 @@
<!--
@source: https://github.com/kytta/share2fedi/blob/main/index.html
---
/*!
* © 2023 Nikita Karamov
* Licensed under AGPL v3 or later
*/
import InstanceSelect from "../components/instance-select.astro";
import "../styles/main.scss";
share2fedi - Instance-agnostic share page for the Fediverse.
Copyright (C) 2020-2023 Nikita Karamov <me@kytta.dev>
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.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
SPDX-License-Identifier: AGPL-3.0-or-later
-->
const searchParameters = new URL(Astro.request.url).searchParams;
const prefilledText = searchParameters.get("text");
const prefilledInstance = searchParameters.get("instance");
---
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
@ -33,26 +24,14 @@
</title>
<meta
name="description"
content="Share₂Fedi is a share page for Mastodon, Pleroma, and GNU Social. Type in your post text and the instance URL and click &lsquo;Publish!&rsquo;"
content="Share₂Fedi is a share page for Mastodon, Pleroma, and GNU Social. Type in your post text and the instance URL and click Publish!"
/>
<link
rel="canonical"
href="https://s2f.kytta.dev/"
/>
<script
type="module"
src="/lib/main.js"
async
defer
></script>
<script
type="module"
src="/lib/count.js"
async
defer
></script>
<script src="../count.js"></script>
<link
rel="icon"
href="/favicon.ico"
@ -71,6 +50,11 @@
rel="manifest"
href="/manifest.webmanifest"
/>
<meta
name="generator"
content="{Astro.generator}"
/>
</head>
<body>
<header>
@ -95,30 +79,11 @@
rows="7"
placeholder="What's on your mind?"
required
></textarea>
>{prefilledText}</textarea
>
</label>
<datalist id="instanceDatalist"></datalist>
<label>
Choose your Mastodon, Pleroma, or GNU Social instance
<input
type="url"
name="instance"
id="instance"
placeholder="https://"
list="instanceDatalist"
required
/>
</label>
<label for="remember">
<input
type="checkbox"
id="remember"
name="remember"
/>
Remember my instance on this device
</label>
<InstanceSelect prefilledInstance={prefilledInstance} />
<input
type="submit"

View File

@ -1,25 +1,10 @@
/*!
* @source: https://github.com/kytta/share2fedi/blob/main/lib/scss/style.scss
*
* share2fedi - Instance-agnostic share page for the Fediverse.
* Copyright (C) 2020-2023 Nikita Karamov <me@kytta.dev>
*
* 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.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* SPDX-License-Identifier: AGPL-3.0-or-later
* © 2023 Nikita Karamov
* Licensed under AGPL v3 or later
*/
@use "sass:math";
*,
*::before,
*::after {
@ -31,6 +16,7 @@
--s2f-accent-color-light: #5d8379;
--s2f-accent-color-contrast: #005e4e;
--s2f-border-color: #ccc;
--s2f-input-group-bg-color: #eee;
--s2f-input-bg-color: #fff;
--s2f-input-text-color: #000;
--s2f-button-text-color: #fff;
@ -103,7 +89,7 @@ textarea {
resize: vertical;
}
input[type="url"],
input[type="text"],
textarea {
width: 100%;
color: var(--s2f-input-text-color);
@ -137,6 +123,7 @@ input[type="submit"] {
--s2f-accent-color-light: #619587;
--s2f-accent-color-contrast: #a8f7e2;
--s2f-border-color: #333;
--s2f-input-group-bg-color: #111;
}
}
@ -147,10 +134,10 @@ input[type="submit"] {
}
main {
width: (200% / 3);
width: math.div(200%, 3);
}
aside {
width: (100% / 3);
width: math.div(100%, 3);
}
}

27
src/util.ts Normal file
View File

@ -0,0 +1,27 @@
/*!
* © 2023 Nikita Karamov
* Licensed under AGPL v3 or later
*/
/**
* Adds missing "https://" and ending slash to the URL
*
* @param url URL to normalize
* @return normalized URL
*/
export const normalizeURL = (url: string): string => {
if (!(url.startsWith("https://") || url.startsWith("http://"))) {
url = "https://" + url;
}
if (!url.endsWith("/")) {
url += "/";
}
return url;
};
export const extractHost = (url: string): string => {
if (!(url.startsWith("https://") || url.startsWith("http://"))) {
url = "https://" + url;
}
return new URL(url).host;
};

3
tsconfig.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "astro/tsconfigs/base"
}

View File

@ -1,25 +0,0 @@
import legacy from "@vitejs/plugin-legacy";
import { ViteImageOptimizer } from "vite-plugin-image-optimizer";
import { VitePluginNode } from "vite-plugin-node";
const vitePluginNode = VitePluginNode({
// Workaround from: https://github.com/axe-me/vite-plugin-node/issues/47
adapter({ app, req, res, next }) {
if (req.url.startsWith("/api/")) {
app(req, res);
} else {
next();
}
},
appPath: "./api/share.js",
});
vitePluginNode[0].apply = "serve";
export default {
build: {
minify: "terser",
terserOptions: { ecma: 5 },
sourcemap: "true",
},
plugins: [legacy(), ...vitePluginNode, ViteImageOptimizer({})],
};