Compare commits

...

7 commits

20 changed files with 303 additions and 108 deletions

0
.noai Normal file
View file

View file

@ -41,6 +41,8 @@ public class MembersController(
return Ok(memberRenderer.RenderMember(member, CurrentToken));
}
public const int MaxMemberCount = 500;
[HttpPost("/api/v2/users/@me/members")]
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
[Authorize("member.create")]
@ -58,6 +60,10 @@ public class MembersController(
.. ValidationUtils.ValidateLinks(req.Links)
]);
var memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct);
if (memberCount >= MaxMemberCount)
throw new ApiError.BadRequest("Maximum number of members reached");
var member = new Member
{
Id = snowflakeGenerator.GenerateSnowflake(),
@ -100,7 +106,9 @@ public class MembersController(
var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
var errors = new List<(string, ValidationError?)>();
if (req.Name != null)
// We might add extra validations for names later down the line.
// These should only take effect when a member's name is changed, not on other changes.
if (req.Name != null && req.Name != member.Name)
{
errors.Add(("name", ValidationUtils.ValidateMemberName(req.Name)));
member.Name = req.Name;

View file

@ -1,3 +1,4 @@
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
namespace Foxnouns.Backend.Controllers;
@ -18,14 +19,22 @@ public class MetaController : ApiControllerBase
(int)FoxnounsMetrics.UsersActiveMonthCount.Value,
(int)FoxnounsMetrics.UsersActiveWeekCount.Value,
(int)FoxnounsMetrics.UsersActiveDayCount.Value
))
),
new Limits(
MemberCount: MembersController.MaxMemberCount,
BioLength: ValidationUtils.MaxBioLength))
);
}
[HttpGet("/api/v2/coffee")]
public IActionResult BrewCoffee() => Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
private record MetaResponse(string Repository, string Version, string Hash, int Members, UserInfo Users);
private record MetaResponse(string Repository, string Version, string Hash, int Members, UserInfo Users, Limits Limits);
private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay);
// All limits that the frontend should know about (for UI purposes)
private record Limits(
int MemberCount,
int BioLength);
}

View file

@ -127,12 +127,14 @@ public static class ValidationUtils
return errors;
}
public const int MaxBioLength = 1024;
public static ValidationError? ValidateBio(string? bio)
{
return bio?.Length switch
{
0 => ValidationError.LengthError("Bio is too short", 1, 1024, bio.Length),
> 1024 => ValidationError.LengthError("Bio is too long", 1, 1024, bio.Length),
0 => ValidationError.LengthError("Bio is too short", 1, MaxBioLength, bio.Length),
> MaxBioLength => ValidationError.LengthError("Bio is too long", 1, MaxBioLength, bio.Length),
_ => null
};
}

View file

@ -1,5 +1,11 @@
import "dotenv/config";
import { env } from "node:process";
import { Limits } from "~/lib/api/meta";
export const API_BASE = env.API_BASE || "https://pronouns.localhost/api";
export const LANGUAGE = env.LANGUAGE || "en";
const apiLimits: Limits = await fetch(`${API_BASE}/v2/meta`)
.then((resp) => resp.json())
.then((m) => m.limits);
export const limits: Limits = Object.freeze(apiLimits);

View file

@ -9,3 +9,8 @@ export default interface Meta {
};
members: number;
}
export type Limits = {
member_count: number;
bio_length: number;
};

View file

@ -1,6 +1,7 @@
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
import { API_BASE } from "~/env.server";
import { ApiError, ErrorCode } from "./api/error";
import { tokenCookieName } from "~/lib/utils";
export type RequestParams = {
token?: string;
@ -39,7 +40,7 @@ export default async function serverRequest<T>(
return (await resp.json()) as T;
}
export const getToken = (req: Request) => getCookie(req, "pronounscc-token");
export const getToken = (req: Request) => getCookie(req, tokenCookieName);
export function getCookie(req: Request, cookieName: string): string | undefined {
const header = req.headers.get("Cookie");
@ -57,4 +58,5 @@ export const writeCookie = (cookieName: string, value: string, maxAge: number |
path: "/",
sameSite: "lax",
httpOnly: true,
secure: true,
});

View file

@ -1 +1,6 @@
import { DateTime } from "luxon";
export const defaultAvatarUrl = "https://pronouns.cc/default/512.webp";
export const tokenCookieName = "__Host-pronounscc-token";
export const idTimestamp = (id: string) =>
DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000);

View file

@ -13,7 +13,7 @@ import { LoaderFunctionArgs } from "@remix-run/node";
import { useChangeLanguage } from "remix-i18next/react";
import { useTranslation } from "react-i18next";
import serverRequest, { getCookie, writeCookie } from "./lib/request.server";
import serverRequest, { getToken, writeCookie } from "./lib/request.server";
import Meta from "./lib/api/meta";
import Navbar from "./components/nav/Navbar";
import { User, UserSettings } from "./lib/api/user";
@ -26,11 +26,12 @@ import { errorCodeDesc } from "./components/ErrorAlert";
import { Container } from "react-bootstrap";
import { ReactNode } from "react";
import BaseNavbar from "~/components/nav/BaseNavbar";
import { tokenCookieName } from "~/lib/utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const meta = await serverRequest<Meta>("GET", "/meta");
const token = getCookie(request, "pronounscc-token");
const token = getToken(request);
let setCookie = "";
let meUser: User | undefined;
@ -43,7 +44,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
} catch (e) {
// If we get an unauthorized error, clear the token, as it's not valid anymore.
if ((e as ApiError).code === ErrorCode.AuthenticationRequired) {
setCookie = writeCookie("pronounscc-token", token, 0);
setCookie = writeCookie(tokenCookieName, token, 0);
}
}
}

View file

@ -93,7 +93,7 @@ export default function UserPage() {
{user.member_title || t("user.heading.members")}{" "}
{isMeUser && (
// @ts-expect-error using as=Link causes an error here, even though it runs completely fine
<Button as={Link} to="/settings/members/create" variant="success">
<Button as={Link} to="/settings/new-member" variant="success">
<PersonPlusFill /> {t("user.create-member-button")}
</Button>
)}

View file

@ -41,7 +41,7 @@ export default function MemberPage() {
<Trans t={t} i18nKey="member.own-profile-alert" values={{ memberName: member.name }}>
You are currently viewing the <strong>public</strong> profile of {{ memberName }}.
<br />
<Link to={`/settings/profile/${member.id}`}>Edit your profile</Link>
<Link to={`/settings/members/${member.id}`}>Edit profile</Link>
</Trans>
</Alert>
)}

View file

@ -14,11 +14,15 @@ import {
useActionData,
useLoaderData,
ShouldRevalidateFunction,
useNavigate,
Navigate,
} from "@remix-run/react";
import { Trans, useTranslation } from "react-i18next";
import { Form, Button, Alert } from "react-bootstrap";
import ErrorAlert from "~/components/ErrorAlert";
import i18n from "~/i18next.server";
import { tokenCookieName } from "~/lib/utils";
import { useEffect } from "react";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
@ -53,7 +57,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
},
{
headers: {
"Set-Cookie": writeCookie("pronounscc-token", resp.token!),
"Set-Cookie": writeCookie(tokenCookieName, resp.token!),
},
},
);
@ -90,7 +94,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return redirect("/auth/welcome", {
headers: {
"Set-Cookie": writeCookie("pronounscc-token", resp.token),
"Set-Cookie": writeCookie(tokenCookieName, resp.token),
},
status: 303,
});
@ -105,6 +109,16 @@ export default function DiscordCallbackPage() {
const { t } = useTranslation();
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigate = useNavigate();
useEffect(() => {
setTimeout(() => {
if (data.hasAccount) {
navigate(`/@${data.user!.username}`);
}
}, 2000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (data.hasAccount) {
const username = data.user!.username;

View file

@ -19,6 +19,7 @@ import { AuthResponse, AuthUrls } from "~/lib/api/auth";
import { ApiError, ErrorCode } from "~/lib/api/error";
import ErrorAlert from "~/components/ErrorAlert";
import { User } from "~/lib/api/user";
import { tokenCookieName } from "~/lib/utils";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
@ -61,7 +62,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return redirect("/", {
status: 303,
headers: {
"Set-Cookie": writeCookie("pronounscc-token", resp.token),
"Set-Cookie": writeCookie(tokenCookieName, resp.token),
},
});
} catch (e) {

View file

@ -1,10 +1,11 @@
import { ActionFunction } from "@remix-run/node";
import { writeCookie } from "~/lib/request.server";
import { tokenCookieName } from "~/lib/utils";
export const action: ActionFunction = async () => {
return new Response(null, {
headers: {
"Set-Cookie": writeCookie("pronounscc-token", "token", 0),
"Set-Cookie": writeCookie(tokenCookieName, "token", 0),
},
status: 204,
});

View file

@ -1,6 +1,6 @@
import { ActionFunction } from "@remix-run/node";
import { UserSettings } from "~/lib/api/user";
import serverRequest, { getCookie, writeCookie } from "~/lib/request.server";
import serverRequest, { getToken, writeCookie } from "~/lib/request.server";
// Handles theme switching
// Remix itself handles redirecting back to the original page after the setting is set
@ -15,7 +15,7 @@ export const action: ActionFunction = async ({ request }) => {
const body = await request.formData();
const theme = (body.get("theme") as string | null) || "auto";
const token = getCookie(request, "pronounscc-token");
const token = getToken(request);
if (token) {
await serverRequest<UserSettings>("PATCH", "/users/@me/settings", {
token,

View file

@ -0,0 +1,49 @@
import { Table } from "react-bootstrap";
import { useTranslation } from "react-i18next";
import { useLoaderData, useRouteLoaderData } from "@remix-run/react";
import { loader as settingsLoader } from "../settings/route";
import { LoaderFunctionArgs, json } from "@remix-run/node";
import serverRequest, { getToken } from "~/lib/request.server";
import { PartialMember } from "~/lib/api/user";
import { limits } from "~/env.server";
import { DateTime } from "luxon";
import { idTimestamp } from "~/lib/utils";
export const loader = async ({ request }: LoaderFunctionArgs) => {
const token = getToken(request);
const members = await serverRequest<PartialMember[]>("GET", "/users/@me/members", { token });
return json({ members, maxMemberCount: limits.member_count });
};
export default function SettingsIndex() {
const { members, maxMemberCount } = useLoaderData<typeof loader>();
const { user } = useRouteLoaderData<typeof settingsLoader>("routes/settings")!;
const { t } = useTranslation();
const createdAt = idTimestamp(user.id);
return (
<>
<Table striped bordered>
<tbody>
<tr>
<th scope="row">{t("settings.general.id")}</th>
<td>
<code>{user.id}</code>
</td>
</tr>
<tr>
<th scope="row">{t("settings.general.created")}</th>
<td>{createdAt.toLocaleString(DateTime.DATETIME_MED)}</td>
</tr>
<tr>
<th scope="row">{t("settings.general.member-count")}</th>
<td>
{members.length}/{maxMemberCount}
</td>
</tr>
</tbody>
</Table>
</>
);
}

View file

@ -0,0 +1,65 @@
import { LoaderFunctionArgs, json, redirect, MetaFunction } from "@remix-run/node";
import i18n from "~/i18next.server";
import serverRequest, { getToken } from "~/lib/request.server";
import { User } from "~/lib/api/user";
import { Link, Outlet, useLocation } from "@remix-run/react";
import { Nav } from "react-bootstrap";
import { useTranslation } from "react-i18next";
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${data?.meta.title || "Settings"} • pronouns.cc` }];
};
export const loader = async ({ request }: LoaderFunctionArgs) => {
const t = await i18n.getFixedT(request);
const token = getToken(request);
if (token) {
try {
const user = await serverRequest<User>("GET", "/users/@me", { token });
return json({ user, meta: { title: t("settings.title") } });
} catch (e) {
return redirect("/auth/log-in");
}
}
return redirect("/auth/log-in");
};
export default function SettingsLayout() {
const { t } = useTranslation();
const { pathname } = useLocation();
return (
<>
<Nav variant="pills" justify fill className="flex-column flex-md-row">
<Nav.Link active={pathname === "/settings"} as={Link} to="/settings">
{t("settings.nav.general-information")}
</Nav.Link>
<Nav.Link
active={pathname.startsWith("/settings/profile")}
as={Link}
to="/settings/profile"
>
{t("settings.nav.profile")}
</Nav.Link>
<Nav.Link
active={pathname.startsWith("/settings/members")}
as={Link}
to="/settings/members"
>
{t("settings.nav.members")}
</Nav.Link>
<Nav.Link active={pathname.startsWith("/settings/auth")} as={Link} to="/settings/auth">
{t("settings.nav.authentication")}
</Nav.Link>
<Nav.Link active={pathname === "/settings/export"} as={Link} to="/settings/export">
{t("settings.nav.export")}
</Nav.Link>
</Nav>
<div className="my-3">
<Outlet />
</div>
</>
);
}

View file

@ -30,6 +30,7 @@
"i18next-fs-backend": "^2.3.2",
"i18next-http-backend": "^2.6.1",
"isbot": "^4.1.0",
"luxon": "^3.5.0",
"markdown-it": "^14.1.0",
"morgan": "^1.10.0",
"react": "^18.2.0",
@ -46,6 +47,7 @@
"@types/compression": "^1.7.5",
"@types/cookie": "^0.6.0",
"@types/express": "^4.17.21",
"@types/luxon": "^3.4.2",
"@types/markdown-it": "^14.1.2",
"@types/morgan": "^1.9.9",
"@types/react": "^18.2.20",

View file

@ -1,93 +1,108 @@
{
"error": {
"heading": "An error occurred",
"validation": {
"too-long": "Value is too long, maximum length is {{maxLength}}, current length is {{actualLength}}.",
"too-short": "Value is too short, minimum length is {{minLength}}, current length is {{actualLength}}.",
"disallowed-value": "The value <1>{{actualValue}}</1> is not allowed here. Allowed values are: <4>{{allowedValues}}</4>",
"generic": "The value <1>{{actualValue}}</1> is not allowed here. Reason: {{reason}}",
"generic-no-value": "The value you entered is not allowed here. Reason: {{reason}}"
},
"errors": {
"authentication-error": "There was an error validating your credentials.",
"authentication-required": "You need to log in.",
"bad-request": "Server rejected your input, please check anything for errors.",
"forbidden": "You are not allowed to perform that action.",
"generic-error": "An unknown error occurred.",
"internal-server-error": "Server experienced an internal error, please try again later.",
"member-not-found": "Member not found, please check your spelling and try again.",
"user-not-found": "User not found, please check your spelling and try again."
},
"title": "An error occurred",
"more-info": "Click here for a more detailed error"
},
"navbar": {
"view-profile": "View profile",
"settings": "Settings",
"log-out": "Log out",
"log-in": "Log in or sign up"
},
"user": {
"avatar-alt": "Avatar for @{{username}}",
"heading": {
"names": "Names",
"pronouns": "Pronouns",
"members": "Members"
},
"member-avatar-alt": "Avatar for {{name}}",
"member-hidden": "This member is unlisted, and not shown in your public member list.",
"own-profile-alert": "You are currently viewing your <1>public</1> profile.<3></3><4>Edit your profile</4>",
"create-member-button": "Create member",
"no-members-blurb": "You don't have any members yet.<1></1>Members are sub-profiles that can have their own avatar, names, pronouns, and preferred terms.<3></3>You can create a new member with the \"Create member\" button above. <6>(only you can see this)</6>"
},
"member": {
"avatar-alt": "Avatar for {{name}}",
"own-profile-alert": "You are currently viewing the <1>public</1> profile of {{memberName}}.<5></5><6>Edit your profile</6>",
"back": "Back to {{name}}"
},
"log-in": {
"callback": {
"title": {
"discord-success": "Log in with Discord",
"discord-register": "Register with Discord"
},
"success": "Successfully logged in!",
"success-link": "Welcome back, <1>@{{username}}</1>!",
"redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.",
"remote-username": {
"discord": "Your discord username"
},
"username": "Username",
"sign-up-button": "Sign up",
"invalid-ticket": "Invalid ticket (it might have been too long since you logged in with Discord), please <2>try again</2>.",
"invalid-username": "Invalid username",
"username-taken": "That username is already taken, please try something else."
},
"title": "Log in",
"form-title": "Log in with email",
"email": "Email address",
"password": "Password",
"log-in-button": "Log in",
"register-with-email": "Register with email",
"3rd-party": {
"title": "Log in with another service",
"desc": "If you prefer, you can also log in with one of these services:",
"discord": "Log in with Discord",
"google": "Log in with Google",
"tumblr": "Log in with Tumblr"
},
"invalid-credentials": "Invalid email address or password, please check your spelling and try again."
},
"welcome": {
"title": "Welcome",
"header": "Welcome to pronouns.cc!",
"blurb": "{welcome.blurb}",
"customize-profile": "Customize your profile",
"customize-profile-blurb": "{welcome.customize-profile-blurb}",
"create-members": "Create members",
"create-members-blurb": "{welcome.create-members-blurb}",
"custom-preferences": "Customize your preferences",
"custom-preferences-blurb": "{welcome.custom-preferences-blurb}",
"profile-button": "Go to your profile"
}
"error": {
"heading": "An error occurred",
"validation": {
"too-long": "Value is too long, maximum length is {{maxLength}}, current length is {{actualLength}}.",
"too-short": "Value is too short, minimum length is {{minLength}}, current length is {{actualLength}}.",
"disallowed-value": "The value <1>{{actualValue}}</1> is not allowed here. Allowed values are: <4>{{allowedValues}}</4>",
"generic": "The value <1>{{actualValue}}</1> is not allowed here. Reason: {{reason}}",
"generic-no-value": "The value you entered is not allowed here. Reason: {{reason}}"
},
"errors": {
"authentication-error": "There was an error validating your credentials.",
"authentication-required": "You need to log in.",
"bad-request": "Server rejected your input, please check anything for errors.",
"forbidden": "You are not allowed to perform that action.",
"generic-error": "An unknown error occurred.",
"internal-server-error": "Server experienced an internal error, please try again later.",
"member-not-found": "Member not found, please check your spelling and try again.",
"user-not-found": "User not found, please check your spelling and try again."
},
"title": "An error occurred",
"more-info": "Click here for a more detailed error"
},
"navbar": {
"view-profile": "View profile",
"settings": "Settings",
"log-out": "Log out",
"log-in": "Log in or sign up"
},
"user": {
"avatar-alt": "Avatar for @{{username}}",
"heading": {
"names": "Names",
"pronouns": "Pronouns",
"members": "Members"
},
"member-avatar-alt": "Avatar for {{name}}",
"member-hidden": "This member is unlisted, and not shown in your public member list.",
"own-profile-alert": "You are currently viewing your <1>public</1> profile.<3></3><4>Edit your profile</4>",
"create-member-button": "Create member",
"no-members-blurb": "You don't have any members yet.<1></1>Members are sub-profiles that can have their own avatar, names, pronouns, and preferred terms.<3></3>You can create a new member with the \"Create member\" button above. <6>(only you can see this)</6>"
},
"member": {
"avatar-alt": "Avatar for {{name}}",
"own-profile-alert": "You are currently viewing the <1>public</1> profile of {{memberName}}.<5></5><6>Edit profile</6>",
"back": "Back to {{name}}"
},
"log-in": {
"callback": {
"title": {
"discord-success": "Log in with Discord",
"discord-register": "Register with Discord"
},
"success": "Successfully logged in!",
"success-link": "Welcome back, <1>@{{username}}</1>!",
"redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.",
"remote-username": {
"discord": "Your discord username"
},
"username": "Username",
"sign-up-button": "Sign up",
"invalid-ticket": "Invalid ticket (it might have been too long since you logged in with Discord), please <2>try again</2>.",
"invalid-username": "Invalid username",
"username-taken": "That username is already taken, please try something else."
},
"title": "Log in",
"form-title": "Log in with email",
"email": "Email address",
"password": "Password",
"log-in-button": "Log in",
"register-with-email": "Register with email",
"3rd-party": {
"title": "Log in with another service",
"desc": "If you prefer, you can also log in with one of these services:",
"discord": "Log in with Discord",
"google": "Log in with Google",
"tumblr": "Log in with Tumblr"
},
"invalid-credentials": "Invalid email address or password, please check your spelling and try again."
},
"welcome": {
"title": "Welcome",
"header": "Welcome to pronouns.cc!",
"blurb": "{welcome.blurb}",
"customize-profile": "Customize your profile",
"customize-profile-blurb": "{welcome.customize-profile-blurb}",
"create-members": "Create members",
"create-members-blurb": "{welcome.create-members-blurb}",
"custom-preferences": "Customize your preferences",
"custom-preferences-blurb": "{welcome.custom-preferences-blurb}",
"profile-button": "Go to your profile"
},
"settings": {
"general": {
"id": "Your user ID",
"created": "Account created at",
"member-count": "Members"
},
"title": "Settings",
"nav": {
"general-information": "General information",
"profile": "Base profile",
"members": "Members",
"authentication": "Authentication",
"export": "Export your data"
}
}
}

View file

@ -1341,6 +1341,11 @@
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
"@types/luxon@^3.4.2":
version "3.4.2"
resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7"
integrity sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==
"@types/markdown-it@^14.1.2":
version "14.1.2"
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61"
@ -4499,6 +4504,11 @@ lru-cache@^7.4.4, lru-cache@^7.5.1, lru-cache@^7.7.1:
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89"
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
luxon@^3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20"
integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==
markdown-extensions@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/markdown-extensions/-/markdown-extensions-1.1.1.tgz#fea03b539faeaee9b4ef02a3769b455b189f7fc3"