From 4b5d3ab0092ed8b17749431b3f284157ab63ff61 Mon Sep 17 00:00:00 2001
From: Eana Hufwe <eana@1a23.com>
Date: Wed, 31 Jul 2024 23:08:32 +0000
Subject: [PATCH] fix: allow arbitrary BCP 47-compliant language via API

---
 packages/backend/src/services/note/create.ts  |  9 ++++-----
 packages/client/src/components/MkPostForm.vue | 11 ++++++++---
 packages/firefish-js/src/index.ts             |  3 ++-
 packages/firefish-js/src/misc/langmap.ts      |  4 ++++
 4 files changed, 18 insertions(+), 9 deletions(-)

diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts
index 4b21c67141..63c6239ccf 100644
--- a/packages/backend/src/services/note/create.ts
+++ b/packages/backend/src/services/note/create.ts
@@ -62,7 +62,7 @@ import { db } from "@/db/postgre.js";
 import { getActiveWebhooks } from "@/misc/webhook-cache.js";
 import { redisClient } from "@/db/redis.js";
 import { Mutex } from "redis-semaphore";
-import { langmap } from "firefish-js";
+import { bcp47Pattern } from "firefish-js";
 import Logger from "@/services/logger.js";
 import { inspect } from "node:util";
 import { toRustObject } from "@/prelude/undefined-to-null.js";
@@ -273,8 +273,7 @@ export default async (
 		data.text = data.text?.trim() ?? null;
 
 		if (data.lang != null) {
-			if (!Object.keys(langmap).includes(data.lang.toLowerCase()))
-				throw new Error("invalid param");
+			if (!bcp47Pattern.test(data.lang)) rej("Invalid language code");
 			data.lang = data.lang.toLowerCase();
 		} else {
 			data.lang = null;
@@ -317,7 +316,7 @@ export default async (
 		}
 
 		if (!isDraft && data.visibility === "specified") {
-			if (data.visibleUsers == null) throw new Error("invalid param");
+			if (data.visibleUsers == null) rej("invalid param");
 
 			for (const u of data.visibleUsers) {
 				if (!mentionedUsers.some((x) => x.id === u.id)) {
@@ -444,7 +443,7 @@ export default async (
 
 				// 未読通知を作成
 				if (data.visibility === "specified") {
-					if (data.visibleUsers == null) throw new Error("invalid param");
+					if (data.visibleUsers == null) rej("invalid param");
 
 					for (const u of data.visibleUsers) {
 						// ローカルユーザーのみ
diff --git a/packages/client/src/components/MkPostForm.vue b/packages/client/src/components/MkPostForm.vue
index 88016cc25d..d408ca0e0b 100644
--- a/packages/client/src/components/MkPostForm.vue
+++ b/packages/client/src/components/MkPostForm.vue
@@ -864,9 +864,14 @@ function setLanguage() {
 			active: true,
 			action: () => {},
 		});
-	} else {
-		// Unrecognized language, set to null
-		language.value = null;
+	} else if (language.value != null) {
+		// Unrecognized language, add it to the list as an ad-hoc language
+		actions.push({
+			text: language.value,
+			danger: false,
+			active: true,
+			action: () => {},
+		});
 	}
 
 	const langs = Object.keys(langmap);
diff --git a/packages/firefish-js/src/index.ts b/packages/firefish-js/src/index.ts
index 957499f359..c134b28d81 100644
--- a/packages/firefish-js/src/index.ts
+++ b/packages/firefish-js/src/index.ts
@@ -13,7 +13,7 @@ import * as entities from "./entities.js";
 import type * as SchemaTypes from "./misc/schema.js";
 import * as Schema from "./misc/schema.js";
 
-import { langmap, type PostLanguage } from "./misc/langmap.js";
+import { langmap, bcp47Pattern, type PostLanguage } from "./misc/langmap.js";
 
 export {
 	type Endpoints,
@@ -27,6 +27,7 @@ export {
 	Schema,
 	type SchemaTypes,
 	langmap,
+	bcp47Pattern,
 	type PostLanguage,
 	api,
 	entities,
diff --git a/packages/firefish-js/src/misc/langmap.ts b/packages/firefish-js/src/misc/langmap.ts
index 16d169d914..1ed301edff 100644
--- a/packages/firefish-js/src/misc/langmap.ts
+++ b/packages/firefish-js/src/misc/langmap.ts
@@ -378,5 +378,9 @@ export const iso639Regional = {
 	},
 };
 
+// Maverify a BCP 57 language tag (by ericP, CC BY-SA 4.0 https://stackoverflow.com/a/60899733)
+export const bcp47Pattern =
+	/^((?<grandfathered>(en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang))|((?<language>([A-Za-z]{2,3}(-(?<extlang>[A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-(?<script>[A-Za-z]{4}))?(-(?<region>[A-Za-z]{2}|[0-9]{3}))?(-(?<variant>[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-(?<extension>[0-9A-WY-Za-wy-z](-[A-Za-z0-9]{2,8})+))*(-(?<privateUse>x(-[A-Za-z0-9]{1,8})+))?)|(?<privateUse1>x(-[A-Za-z0-9]{1,8})+))$/i;
+
 export const langmap = Object.assign({}, langmapNoRegion, iso639Regional);
 export type PostLanguage = keyof typeof langmap;