Repository: getindiekit/indiekit (js)

Issue:
`RangeError: Invalid time zone specified: Europe/Rome` when posting notes from IndiePass
### Description of the bug

I am not really sure this is an Indiekit bug. I spent some time trying to understand what's going on, but couldn't quite figure it out. I think the problem could be one the following:

- I messed up my Indiekit configuration
- I messed up my Caddy configuration
- I messed up my Debian configuration
- IndiePass messes up the timezone before POSTing the note to the Indiekit's micropub endpoint
- bug in `endpoint-micropub/lib/post-data.js`
- bug in the `getTimeZoneOffset` function of [data-fns-tz](https://github.com/marnusw/date-fns-tz/tree/master). In particular, [this line](https://github.com/marnusw/date-fns-tz/blob/a92e0ad017d101a0c50e39a63ef5d322b4d849f6/src/format/formatters/index.js#L102C11-L102C38).

If I set `DEBUG=indiekit:*`, this is what I see:

```txt
2024-02-14T12:24:32.510Z indiekit:request url POST /micropub

2024-02-14T12:24:32.510Z indiekit:request headers {
  host: 'api.giacomodebidda.com',
  'user-agent': 'Dalvik/2.1.0 (Linux; U; Android 12; Redmi Note 9S Build/SKQ1.211019.001)',
  'content-length': '145',
  accept: 'application/json',
  'accept-encoding': 'gzip',
  authorization: 'Bearer <REDACTED>',
  'cdn-loop': 'cloudflare',
  'cf-connecting-ip': '2001:b07:646b:9c55:9416:1505:b84d:a100',
  'cf-ipcountry': 'IT',
  'cf-ray': '855549e2ec503750-MXP',
  'cf-visitor': '{"scheme":"https"}',
  'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
  'x-forwarded-for': '162.158.129.179',
  'x-forwarded-host': 'api.giacomodebidda.com',
  'x-forwarded-proto': 'https'
}

2024-02-14T12:24:32.511Z indiekit:request body {
  h: 'entry',
  content: 'Testing indiepass',
  category: [ 'Indiepass', 'testing' ],
  published: '2024-02-14T13:24:00+0100',
  'post-status': 'published'
}

2024-02-14T12:24:32.572Z indiekit:request url POST /auth/introspect?token=<REDACTED>

2024-02-14T12:24:32.572Z indiekit:request headers {
  host: 'api.giacomodebidda.com',
  'user-agent': 'node',
  'content-length': '0',
  accept: 'application/json',
  'accept-encoding': 'gzip',
  'accept-language': '*',
  authorization: 'Bearer <REDACTED>',
  'cdn-loop': 'cloudflare',
  'cf-connecting-ip': '35.246.225.169',
  'cf-ipcountry': 'DE',
  'cf-ray': '855549e36ef53609-FRA',
  'cf-visitor': '{"scheme":"https"}',
  'sec-fetch-mode': 'cors',
  'x-forwarded-for': '162.158.110.117',
  'x-forwarded-host': 'api.giacomodebidda.com',
  'x-forwarded-proto': 'https'
}

2024-02-14T12:24:32.573Z indiekit:request body {}

2024-02-14T12:24:32.590Z indiekit:error error RangeError: Invalid time zone specified: Europe/Rome
    at getTimeZoneOffset (file:///home/giacomo/indiekit-server/node_modules/date-fns-tz/esm/format/formatters/index.js:103:11)
    at Object.X (file:///home/giacomo/indiekit-server/node_modules/date-fns-tz/esm/format/formatters/index.js:9:26)
    at file:///home/giacomo/indiekit-server/node_modules/date-fns-tz/esm/format/index.js:334:35
    at Array.reduce (<anonymous>)
    at Module.format (file:///home/giacomo/indiekit-server/node_modules/date-fns-tz/esm/format/index.js:326:25)
    at getDate (file:///home/giacomo/indiekit-server/node_modules/@indiekit/util/lib/date.js:74:39)
    at normaliseProperties (file:///home/giacomo/indiekit-server/node_modules/@indiekit/endpoint-micropub/lib/jf2.js:71:26)
    at Object.create (file:///home/giacomo/indiekit-server/node_modules/@indiekit/endpoint-micropub/lib/post-data.js:29:18)
    at actionController (file:///home/giacomo/indiekit-server/node_modules/@indiekit/endpoint-micropub/lib/controllers/action.js:54:31)
    at Layer.handle [as handle_request] (/home/giacomo/indiekit-server/node_modules/express/lib/router/layer.js:95:5)
```

### Steps to reproduce

Here is what I did.

1. I posted a note to Indiekit using [IndiePass](https://indiepass.app/) on my Android phone. The `user-agent` that appears in the Indiekit logs is `Dalvik/2.1.0 (Linux; U; Android 12; Redmi Note 9S Build/SKQ1.211019.001)`
2. I saw a toast message on IndiePass telling that there was an internal error (HTTP 500) 


### Expected behaviour

I should be able to post a note to Indiekit using IndiePass.

### Environment

- Indiekit version: 1.0.0-beta.7
- IndiePass version: 0.84
- Server: Debian 12 on Compute Engine, with Caddy in front of Indiekit (reverse proxy)

This is my `indiekit.config.js` configuration:

```js
const config = {
  application: {
    hasDatabase: true,
    locale: "en",
    name: "My IndieWeb Server",
    port: 5000,
    themeColor: "#663399",
    timeZone: "Europe/Rome", // looks good to me: no white spaces, no typos
  },

  plugins: [
    "@indiekit/preset-jekyll",
    "@indiekit/store-github",
    "@indiekit/syndicator-mastodon",
  ],

  publication: {
    locale: "en",
    me: "https://giacomodebidda.com",
  },

  "@indiekit/store-github": {
    user: "jackdbd",
    repo: "indiekit-content",
    branch: "main",
  },

  "@indiekit/syndicator-mastodon": {
    checked: true,
    url: "https://fosstodon.org",
    user: "jackdbd",
  },
};

export default config;
```

### Additional context

I tried executing this code from date-fns-tz and it works fine:

```js
getDate("Europe/Rome", "2024-02-14T13:24:00+0100")
```

While this code throws (note the white space):

```js
getDate("Europe/Rome ", "2024-02-14T13:24:00+0100")
```

My guess is that _something_ messes up the timezone. But I couldn't figure out what this _something_ is.

Pull request description:
Don’t decode date parameter values in Micropub form-encoded query strings
Form-encoded query strings are passed to the `decodeQueryParameter` method, where the URL is decoded and `+`s get replaced with spaces. However, given the following query:

```
h=entry&content=Testing+indiepass&post-status=published&category%5B%5D=Indiepass%2C+testing&published=2024-02-14T13%3A24%3A00%2B0100&post-status=published`
```

While `Testing+indiepass` is correctly converted to `Testing indiepass`, `2024-02-14T13%3A24%3A00%2B0100` is converted to `2024-02-14T13:24:00 0100` – the `+` gets replaced with a space. However, in date strings, `+` is used for time zone offsets, and so removing it causes parsing and conversion issues further along the pipeline.

This PR adds a new `isDate` utility method, that checks if a value can be converted to a `Date` and successful be parsed `toISOString()`. This check is then used in the Micropub endpoint when parsing form-encoded strings; if a string can be parsed as a date, we don’t replace the `+`.

Fixes #691.

Patch:
diff --git a/packages/endpoint-micropub/lib/jf2.js b/packages/endpoint-micropub/lib/jf2.js
index b676f42f..0638aaa4 100644
--- a/packages/endpoint-micropub/lib/jf2.js
+++ b/packages/endpoint-micropub/lib/jf2.js
@@ -30,12 +30,8 @@ export const formEncodedToJf2 = async (body, requestReferences) => {
         continue;
       }
 
-      // Decode string values
-      const isStringValue = typeof body[key] === "string";
-      const value = isStringValue ? decodeQueryParameter(body[key]) : body[key];
-
-      // Adds values to JF2 object
-      jf2[key] = value;
+      // Add decoded string value to JF2 object
+      jf2[key] = decodeQueryParameter(body[key]);
     }
   }
 
diff --git a/packages/endpoint-micropub/lib/utils.js b/packages/endpoint-micropub/lib/utils.js
index 71f4e6ef..c76dabe5 100644
--- a/packages/endpoint-micropub/lib/utils.js
+++ b/packages/endpoint-micropub/lib/utils.js
@@ -1,17 +1,26 @@
-import { getTimeZoneDesignator, supplant } from "@indiekit/util";
+import { getTimeZoneDesignator, isDate, supplant } from "@indiekit/util";
 import { format } from "date-fns-tz";
 import newbase60 from "newbase60";
 import { postTypeCount } from "./post-type-count.js";
 
 /**
  * Decode form-encoded query parameter
- * @param {string} string - String to decode
- * @returns {string} Decoded string
- * @example decodeQueryParameter('foo+bar') => 'foo bar'
+ * @param {*} value - Parameter value to decode
+ * @returns {*} Decoded string, else original parameter value
+ * @example decodeQueryParameter(['foo', 'bar']) => ['foo', 'bar']
+ * @example decodeQueryParameter('2024-02-14T13:24:00+0100') => '2024-02-14T13:24:00+0100'
  * @example decodeQueryParameter('https%3A%2F%2Ffoo.bar') => 'https://foo.bar'
+ * @example decodeQueryParameter('foo+bar') => 'foo bar'
  */
-export const decodeQueryParameter = (string) =>
-  decodeURIComponent(string.replaceAll("+", " "));
+export const decodeQueryParameter = (value) => {
+  if (typeof value !== "string") {
+    return value;
+  }
+
+  return isDate(value)
+    ? decodeURIComponent(value)
+    : decodeURIComponent(value.replaceAll("+", " "));
+};
 
 /**
  * Excerpt the first n words from a string
diff --git a/packages/util/index.js b/packages/util/index.js
index 7458317f..e47f3d6e 100644
--- a/packages/util/index.js
+++ b/packages/util/index.js
@@ -5,6 +5,7 @@ export {
   getDate,
   getTimeZoneDesignator,
   getTimeZoneOffset,
+  isDate,
 } from "./lib/date.js";
 export { sanitise } from "./lib/object.js";
 export { ISO_6709_RE } from "./lib/regex.js";
diff --git a/packages/util/lib/date.js b/packages/util/lib/date.js
index c0116d2a..ec8f86f0 100644
--- a/packages/util/lib/date.js
+++ b/packages/util/lib/date.js
@@ -126,3 +126,18 @@ export const getTimeZoneOffset = (timeZone, date) => {
   const offset = minutes === 0 ? 0 : minutes * -1;
   return offset;
 };
+
+/**
+ * Check if a string can be parsed as a date
+ * @param {string} string - String
+ * @returns {boolean} String is a date
+ */
+export const isDate = (string) => {
+  try {
+    const date = new Date(string);
+    date.toISOString();
+    return true;
+  } catch {
+    return false;
+  }
+};


Test patch:
diff --git a/packages/endpoint-micropub/test/unit/utils.js b/packages/endpoint-micropub/test/unit/utils.js
index 362ba997..dd5def37 100644
--- a/packages/endpoint-micropub/test/unit/utils.js
+++ b/packages/endpoint-micropub/test/unit/utils.js
@@ -16,9 +16,16 @@ describe("endpoint-media/lib/utils", () => {
   });
 
   it("Decodes form-encoded query parameter", () => {
-    const result = decodeQueryParameter("https%3A%2F%2Ffoo.bar");
-
-    assert.equal(result, "https://foo.bar");
+    assert.deepEqual(decodeQueryParameter(["foo", "bar"]), ["foo", "bar"]);
+    assert.equal(
+      decodeQueryParameter("2024-02-14T13%3A24%3A00%2B0100"),
+      "2024-02-14T13:24:00+0100",
+    );
+    assert.equal(
+      decodeQueryParameter("https%3A%2F%2Ffoo.bar"),
+      "https://foo.bar",
+    );
+    assert.equal(decodeQueryParameter("foo+bar"), "foo bar");
   });
 
   it("Excerpts the first n words from a string", () => {
diff --git a/packages/util/test/unit/date.js b/packages/util/test/unit/date.js
index 687b2014..0ba36123 100644
--- a/packages/util/test/unit/date.js
+++ b/packages/util/test/unit/date.js
@@ -7,6 +7,7 @@ import {
   getDate,
   getTimeZoneDesignator,
   getTimeZoneOffset,
+  isDate,
 } from "../../lib/date.js";
 
 const { isValid, parseISO } = dateFns;
@@ -204,4 +205,10 @@ describe("util/lib/date", () => {
     assert.equal(getTimeZoneOffset("America/Panama", date), 300);
     assert.equal(getTimeZoneOffset("UTC", date), 0);
   });
+
+  it("Check if a string can be parsed as a date", () => {
+    assert.equal(isDate("2024-02-14T13:24:00+0100"), true);
+    assert.equal(isDate("Wed Feb 14 2024 13:24:00 GMT+0100"), true);
+    assert.equal(isDate("valentines+day"), false);
+  });
 });