Compare commits
8 Commits
add-anothe
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
a29575e54e | |
|
|
b2c8662861 | |
|
|
a17798fe64 | |
|
|
e81d59f2aa | |
|
|
4e0ea3b324 | |
|
|
0e77c35fb2 | |
|
|
5cf4e2c152 | |
|
|
a88e0cda7f |
|
|
@ -1,3 +1,4 @@
|
||||||
|
test-script.ts
|
||||||
tmp
|
tmp
|
||||||
|
|
||||||
# Created by https://www.toptal.com/developers/gitignore/api/node,deno,macos,emacs
|
# Created by https://www.toptal.com/developers/gitignore/api/node,deno,macos,emacs
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@ import { assertEquals, assertExists, assertGreaterOrEqual } from "$std/assert/mo
|
||||||
import { nrkRadio } from "./nrk.ts";
|
import { nrkRadio } from "./nrk.ts";
|
||||||
import { forTestingOnly } from "./nrk.ts";
|
import { forTestingOnly } from "./nrk.ts";
|
||||||
|
|
||||||
Deno.test("Verify search query `trygd` returns one result: 'Trygdekontoret'", async () => {
|
Deno.test(
|
||||||
const result = await nrkRadio.search("trygd");
|
"Verify search query `trygd` returns one result: 'Trygdekontoret'",
|
||||||
assertExists(result);
|
async () => {
|
||||||
assertEquals(result.length, 1);
|
const result = await nrkRadio.search("trygd");
|
||||||
assertEquals(result[0].seriesId, "trygdekontoret");
|
assertExists(result);
|
||||||
});
|
assertEquals(result.length, 1);
|
||||||
|
assertEquals(result[0].seriesId, "trygdekontoret");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
Deno.test("Verify empty search query yields `null`", async () => {
|
Deno.test("Verify empty search query yields `null`", async () => {
|
||||||
const result = await nrkRadio.search("");
|
const result = await nrkRadio.search("");
|
||||||
|
|
@ -40,13 +43,83 @@ Deno.test("Verify getting series data for 'trygd' does not works", async () => {
|
||||||
assertEquals(result, null);
|
assertEquals(result, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Verify getting episodeId 'l_0bc5e55a-46b5-48a5-85e5-5a46b5d8a562' for 'trygdekontoret' works", async () => {
|
Deno.test(
|
||||||
const result = await nrkRadio.getEpisode("trygdekontoret", "l_0bc5e55a-46b5-48a5-85e5-5a46b5d8a562");
|
"Verify getting episodeId 'l_0bc5e55a-46b5-48a5-85e5-5a46b5d8a562' for 'trygdekontoret' works",
|
||||||
|
async () => {
|
||||||
|
const result = await nrkRadio.getEpisode(
|
||||||
|
"trygdekontoret",
|
||||||
|
"l_0bc5e55a-46b5-48a5-85e5-5a46b5d8a562",
|
||||||
|
);
|
||||||
|
assertExists(result);
|
||||||
|
assertEquals(result.duration.seconds, 4152);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"Verify getting episodeId 'null' for 'trygdekontoret' yields `null`",
|
||||||
|
async () => {
|
||||||
|
const result = await nrkRadio.getEpisode("trygdekontoret", "null");
|
||||||
|
assertEquals(result, null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test("Can get episodes for seriesnakk", async () => {
|
||||||
|
const result = await nrkRadio.getSeries("seriesnakk");
|
||||||
assertExists(result);
|
assertExists(result);
|
||||||
assertEquals(result.duration.seconds, 4152);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Verify getting episodeId 'null' for 'trygdekontoret' yields `null`", async () => {
|
Deno.test("Seriesnakk, an umbrella series yields all seasons", async () => {
|
||||||
const result = await nrkRadio.getEpisode("trygdekontoret", "null");
|
const seriesData = await forTestingOnly.getSeriesData("seriesnakk");
|
||||||
assertEquals(result, null);
|
assertExists(seriesData);
|
||||||
|
assertGreaterOrEqual(seriesData.episodes.length, 1);
|
||||||
|
const titles = seriesData.episodes.map((e) => e.titles.title);
|
||||||
|
|
||||||
|
const thereseTitle = "Therese-saken: Hva kan et vitne huske? (1:5)";
|
||||||
|
assertEquals(
|
||||||
|
titles.includes(thereseTitle),
|
||||||
|
true,
|
||||||
|
`Titles include "${thereseTitle}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const maktaTitle = "– Vi er ikke idioter og tror at det var elsparkesykler i 1975";
|
||||||
|
assertEquals(
|
||||||
|
titles.includes(maktaTitle),
|
||||||
|
true,
|
||||||
|
`Titles include "${maktaTitle}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const soLongMarianneTitle = "- Se og hør sov på dørmatta hennes! (1:8)";
|
||||||
|
assertEquals(
|
||||||
|
titles.includes(soLongMarianneTitle),
|
||||||
|
true,
|
||||||
|
`Titles include "${soLongMarianneTitle}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const nerdrumTitle = "Seriesnakk: Familien Nerdrum";
|
||||||
|
assertEquals(
|
||||||
|
titles.includes(nerdrumTitle),
|
||||||
|
true,
|
||||||
|
`Titles include "${nerdrumTitle}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selinaTitle = "– Som å se meg selv (1:6)";
|
||||||
|
assertEquals(
|
||||||
|
titles.includes(selinaTitle),
|
||||||
|
true,
|
||||||
|
`Titles include "${selinaTitle}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const skamTitle = "– William må svare (4:9)";
|
||||||
|
assertEquals(
|
||||||
|
titles.includes(skamTitle),
|
||||||
|
true,
|
||||||
|
`Titles include "${skamTitle}"`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const natoTitle = "– Jeg ville slite ut Jens (1:2)";
|
||||||
|
assertEquals(
|
||||||
|
titles.includes(natoTitle),
|
||||||
|
true,
|
||||||
|
`Titles include "${natoTitle}"`,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { get, STATUS_CODE } from "https://deno.land/x/kall@v2.0.0/mod.ts";
|
import { get, STATUS_CODE } from "https://deno.land/x/kall@v2.0.0/mod.ts";
|
||||||
import { components as searchComponents } from "./nrk-search.ts";
|
import { Series } from "../storage.ts";
|
||||||
import { components as catalogComponents } from "./nrk-catalog.ts";
|
import { components as catalogComponents } from "./nrk-catalog.ts";
|
||||||
import { external as playbackComponents } from "./nrk-playback.ts";
|
import { external as playbackComponents } from "./nrk-playback.ts";
|
||||||
import { Series } from "../storage.ts";
|
import { components as searchComponents } from "./nrk-search.ts";
|
||||||
|
|
||||||
type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
|
type ArrayElement<A> = A extends readonly (infer T)[] ? T : never;
|
||||||
type PodcastEpisodes = catalogComponents["schemas"]["EpisodesHalResource"];
|
type PodcastEpisodes = catalogComponents["schemas"]["EpisodesHalResource"];
|
||||||
|
|
@ -10,6 +10,7 @@ type Podcast = catalogComponents["schemas"]["SeriesHalResource"];
|
||||||
type PodcastEpisodesSingle = catalogComponents["schemas"]["EpisodeHalResource"];
|
type PodcastEpisodesSingle = catalogComponents["schemas"]["EpisodeHalResource"];
|
||||||
type RadioSeriesEpisode = catalogComponents["schemas"]["EpisodesHalResource"];
|
type RadioSeriesEpisode = catalogComponents["schemas"]["EpisodesHalResource"];
|
||||||
type RadioSeries = catalogComponents["schemas"]["SeriesHalResource"];
|
type RadioSeries = catalogComponents["schemas"]["SeriesHalResource"];
|
||||||
|
type SeasonEpisodes = catalogComponents["schemas"]["PodcastSeasonHalResource"];
|
||||||
|
|
||||||
export type NrkSerie = catalogComponents["schemas"]["SeriesViewModel"];
|
export type NrkSerie = catalogComponents["schemas"]["SeriesViewModel"];
|
||||||
export type NrkOriginalEpisode = PodcastEpisodesSingle & { url: string };
|
export type NrkOriginalEpisode = PodcastEpisodesSingle & { url: string };
|
||||||
|
|
@ -18,11 +19,15 @@ export type NrkSearchResultList = searchComponents["schemas"]["seriesResult"]["r
|
||||||
export type SearchResult = ArrayElement<NrkSearchResultList> & {
|
export type SearchResult = ArrayElement<NrkSearchResultList> & {
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
export type SeriesData = { episodes: NrkOriginalEpisode[] } & (RadioSeries["series"] | Podcast["series"]);
|
export type SeriesData =
|
||||||
|
& { episodes: NrkOriginalEpisode[] }
|
||||||
|
& (
|
||||||
|
| RadioSeries["series"]
|
||||||
|
| Podcast["series"]
|
||||||
|
);
|
||||||
|
|
||||||
function parseSeries(nrkSeriesData: SeriesData): Series {
|
function parseSeries(nrkSeriesData: SeriesData): Series {
|
||||||
const imageUrl = nrkSeriesData.squareImage?.at(-1)?.url ?? "";
|
const imageUrl = nrkSeriesData.squareImage?.at(-1)?.url ?? "";
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: nrkSeriesData.id,
|
id: nrkSeriesData.id,
|
||||||
title: nrkSeriesData.titles.title,
|
title: nrkSeriesData.titles.title,
|
||||||
|
|
@ -62,6 +67,41 @@ async function search(query: string): Promise<NrkSearchResultList | null> {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function extractEpisodes(
|
||||||
|
serieResponse: RadioSeries,
|
||||||
|
episodeResponse?: PodcastEpisodes,
|
||||||
|
): Promise<NrkOriginalEpisode[]> {
|
||||||
|
if (serieResponse.seriesType === "umbrella") {
|
||||||
|
const seasons = await Promise.all(
|
||||||
|
serieResponse._links.seasons.map(async (season) => {
|
||||||
|
const response = await get<SeasonEpisodes>(
|
||||||
|
`https://psapi.nrk.no${season.href}`,
|
||||||
|
);
|
||||||
|
return response.body;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const episodes = await Promise.all(
|
||||||
|
seasons.flatMap((season) => {
|
||||||
|
return (
|
||||||
|
season?._embedded.episodes?._embedded.episodes?.flatMap((episode) =>
|
||||||
|
getEpisodeWithDownloadLink(episode, serieResponse.type)
|
||||||
|
) ?? []
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return episodes;
|
||||||
|
} else {
|
||||||
|
const episodes = await Promise.all(
|
||||||
|
episodeResponse?._embedded.episodes?.map((episode) => getEpisodeWithDownloadLink(episode, serieResponse.type)) ??
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return episodes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function getSeriesData(seriesId: string): Promise<SeriesData | null> {
|
async function getSeriesData(seriesId: string): Promise<SeriesData | null> {
|
||||||
let [
|
let [
|
||||||
{ status: episodeStatus, body: episodeResponse },
|
{ status: episodeStatus, body: episodeResponse },
|
||||||
|
|
@ -70,9 +110,7 @@ async function getSeriesData(seriesId: string): Promise<SeriesData | null> {
|
||||||
get<PodcastEpisodes>(
|
get<PodcastEpisodes>(
|
||||||
`${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes`,
|
`${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes`,
|
||||||
),
|
),
|
||||||
get<Podcast>(
|
get<Podcast>(`${nrkAPI}/radio/catalog/podcast/${seriesId}`),
|
||||||
`${nrkAPI}/radio/catalog/podcast/${seriesId}`,
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (episodeStatus !== STATUS_CODE.OK || seriesStatus !== STATUS_CODE.OK) {
|
if (episodeStatus !== STATUS_CODE.OK || seriesStatus !== STATUS_CODE.OK) {
|
||||||
|
|
@ -83,19 +121,17 @@ async function getSeriesData(seriesId: string): Promise<SeriesData | null> {
|
||||||
get<RadioSeriesEpisode>(
|
get<RadioSeriesEpisode>(
|
||||||
`https://psapi.nrk.no/radio/catalog/series/${seriesId}/episodes`,
|
`https://psapi.nrk.no/radio/catalog/series/${seriesId}/episodes`,
|
||||||
),
|
),
|
||||||
get<RadioSeries>(
|
get<RadioSeries>(`https://psapi.nrk.no/radio/catalog/series/${seriesId}`),
|
||||||
`https://psapi.nrk.no/radio/catalog/series/${seriesId}`,
|
|
||||||
),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
episodeStatus === STATUS_CODE.OK && seriesStatus === STATUS_CODE.OK &&
|
episodeStatus === STATUS_CODE.OK &&
|
||||||
serieResponse?.series && episodeResponse?._embedded.episodes?.length
|
seriesStatus === STATUS_CODE.OK &&
|
||||||
|
serieResponse?.series &&
|
||||||
|
episodeResponse?._embedded.episodes?.length
|
||||||
) {
|
) {
|
||||||
const episodes = await Promise.all(
|
const episodes = await extractEpisodes(serieResponse, episodeResponse);
|
||||||
episodeResponse._embedded.episodes.map((episode) => getEpisodeWithDownloadLink(episode, serieResponse.type)),
|
|
||||||
);
|
|
||||||
const seriesData = {
|
const seriesData = {
|
||||||
...serieResponse.series,
|
...serieResponse.series,
|
||||||
episodes,
|
episodes,
|
||||||
|
|
@ -117,13 +153,18 @@ async function getSeries(seriesId: string): Promise<Series | null> {
|
||||||
return parsedSeries;
|
return parsedSeries;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getEpisode(seriesId: string, episodeId: string): Promise<NrkPodcastEpisode | null> {
|
async function getEpisode(
|
||||||
|
seriesId: string,
|
||||||
|
episodeId: string,
|
||||||
|
): Promise<NrkPodcastEpisode | null> {
|
||||||
const url = `${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes/${episodeId}`;
|
const url = `${nrkAPI}/radio/catalog/podcast/${seriesId}/episodes/${episodeId}`;
|
||||||
const { status, body: episode } = await get<NrkPodcastEpisode>(url);
|
const { status, body: episode } = await get<NrkPodcastEpisode>(url);
|
||||||
if (status === STATUS_CODE.OK && episode) {
|
if (status === STATUS_CODE.OK && episode) {
|
||||||
return episode;
|
return episode;
|
||||||
}
|
}
|
||||||
console.error(`Error getting episode ${episodeId}. Status: ${status}. Series: ${seriesId}`);
|
console.error(
|
||||||
|
`Error getting episode ${episodeId}. Status: ${status}. Series: ${seriesId}`,
|
||||||
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { assertEquals, assertExists } from "$std/assert/mod.ts";
|
import { assertEquals, assertExists, assertLess } from "$std/assert/mod.ts";
|
||||||
import { testUtils } from "./test-utils.ts";
|
import { testUtils } from "./test-utils.ts";
|
||||||
import { rss } from "./rss.ts";
|
import { rss } from "./rss.ts";
|
||||||
import { forTestingOnly } from "./rss.ts";
|
import { forTestingOnly } from "./rss.ts";
|
||||||
|
|
@ -30,6 +30,29 @@ Deno.test("generated rss contains promo with link to donations page", () => {
|
||||||
const series = testUtils.generateSeries();
|
const series = testUtils.generateSeries();
|
||||||
const feed = rss.assembleFeed(series);
|
const feed = rss.assembleFeed(series);
|
||||||
|
|
||||||
console.log(feed);
|
|
||||||
feed.includes("Vurder å støtte utviklingen via Vipps");
|
feed.includes("Vurder å støtte utviklingen via Vipps");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("generated rss contains promo with link to donations page", () => {
|
||||||
|
const series = testUtils.generateSeries();
|
||||||
|
const feed = rss.assembleFeed(series);
|
||||||
|
|
||||||
|
const indexOfFirstPromotion = feed.indexOf(
|
||||||
|
"NRSS er avhengig av din Vipps-støtte",
|
||||||
|
);
|
||||||
|
const indexOfSecondPromotion = feed.indexOf("Vurder å støtte utviklingen");
|
||||||
|
assertLess(
|
||||||
|
indexOfFirstPromotion,
|
||||||
|
indexOfSecondPromotion,
|
||||||
|
"First promotion should come before the second",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("first promotion is removed", () => {
|
||||||
|
const series = testUtils.generateSeries();
|
||||||
|
const feed = rss.assembleFeed(series);
|
||||||
|
const indexOfFirstPromotion = feed.indexOf(
|
||||||
|
"NRSS er avhengig av din Vipps-støtte",
|
||||||
|
);
|
||||||
|
assertEquals(indexOfFirstPromotion, -1, "First promotion should be removed");
|
||||||
|
});
|
||||||
|
|
|
||||||
14
lib/rss.ts
14
lib/rss.ts
|
|
@ -27,16 +27,11 @@ function assembleFeed(series: Series): string {
|
||||||
tag("itunes:name", "NRK"),
|
tag("itunes:name", "NRK"),
|
||||||
tag("itunes:email", "nrkpodcast@nrk.no"),
|
tag("itunes:email", "nrkpodcast@nrk.no"),
|
||||||
]),
|
]),
|
||||||
tag(
|
tag("description", series.subtitle || ""),
|
||||||
"description",
|
|
||||||
series.subtitle || "",
|
|
||||||
),
|
|
||||||
tag("ttl", "60"), //60 minutes
|
tag("ttl", "60"), //60 minutes
|
||||||
...(series.imageUrl
|
...(series.imageUrl
|
||||||
? [
|
? [
|
||||||
tag("itunes:image", "", [
|
tag("itunes:image", "", [["href", series.imageUrl]]),
|
||||||
["href", series.imageUrl],
|
|
||||||
]),
|
|
||||||
tag("image", [
|
tag("image", [
|
||||||
tag("url", series.imageUrl),
|
tag("url", series.imageUrl),
|
||||||
tag("title", series.title),
|
tag("title", series.title),
|
||||||
|
|
@ -76,10 +71,7 @@ function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag {
|
||||||
tag("pubDate", new Date(episode.date).toUTCString()),
|
tag("pubDate", new Date(episode.date).toUTCString()),
|
||||||
tag("itunes:duration", episode.durationInSeconds.toString()),
|
tag("itunes:duration", episode.durationInSeconds.toString()),
|
||||||
tag("podcast:chapters", "", [
|
tag("podcast:chapters", "", [
|
||||||
[
|
["url", `${getHostUrl()}/api/feeds/${seriesId}/${episode.id}/chapters`],
|
||||||
"url",
|
|
||||||
`${getHostUrl()}/api/feeds/${seriesId}/${episode.id}/chapters`,
|
|
||||||
],
|
|
||||||
["type", "application/json+chapters"],
|
["type", "application/json+chapters"],
|
||||||
]),
|
]),
|
||||||
tag("enclosure", "", [
|
tag("enclosure", "", [
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { assertEquals, assertNotEquals } from "$std/assert/mod.ts";
|
import { assertEquals, assertNotEquals } from "$std/assert/mod.ts";
|
||||||
import { responseJSON, responseXML } from "./utils.ts";
|
import { responseJSON, responseXML, withExpiry } from "./utils.ts";
|
||||||
|
|
||||||
Deno.test("JSON response is stringified", async () => {
|
Deno.test("JSON response is stringified", async () => {
|
||||||
const body = { message: "Hello, World!" };
|
const body = { message: "Hello, World!" };
|
||||||
|
|
@ -14,3 +14,41 @@ Deno.test("XML resopnse is _not_ stringified", async () => {
|
||||||
assertEquals(responseBody, body);
|
assertEquals(responseBody, body);
|
||||||
assertNotEquals(responseBody, JSON.stringify(body));
|
assertNotEquals(responseBody, JSON.stringify(body));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"Response with cache control returns a response with the correct header",
|
||||||
|
() => {
|
||||||
|
const response = responseJSON({ message: "Hello, World!" }, 200);
|
||||||
|
const cachedResponse = withExpiry(response, 3600);
|
||||||
|
assertEquals(cachedResponse.headers.get("Cache-Control"), "max-age=3600");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"Response with cache control returns the same body as the original",
|
||||||
|
async () => {
|
||||||
|
const body = { message: "Hello, World!" };
|
||||||
|
const response = responseJSON(body, 200);
|
||||||
|
const cachedResponse = withExpiry(response, 3600);
|
||||||
|
assertEquals(await cachedResponse.text(), await response.text());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Deno.test("Response with cache control does not modify the original", () => {
|
||||||
|
const response = responseJSON({ message: "Hello, World!" }, 200);
|
||||||
|
withExpiry(response, 3600);
|
||||||
|
assertEquals(response.headers.get("Cache-Control"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test(
|
||||||
|
"Response with cache control returns the correct number of seconds",
|
||||||
|
() => {
|
||||||
|
const response = responseJSON({ message: "Hello, World!" }, 200);
|
||||||
|
const ttlInSeconds = 1234;
|
||||||
|
const cachedResponse = withExpiry(response, ttlInSeconds);
|
||||||
|
assertEquals(
|
||||||
|
cachedResponse.headers.get("Cache-Control"),
|
||||||
|
`max-age=${ttlInSeconds}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,13 @@ export function responseXML(body: string, status: Status) {
|
||||||
return response(body, status, "xml");
|
return response(body, status, "xml");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const withExpiry = <T>(response: Response, ttlInSeconds: number) => {
|
||||||
|
const clonedResponse = response.clone();
|
||||||
|
clonedResponse.headers.set("Cache-Control", `max-age=${ttlInSeconds}`);
|
||||||
|
clonedResponse.headers.set("Expires", new Date(Date.now() + ttlInSeconds * 1000).toUTCString());
|
||||||
|
return clonedResponse;
|
||||||
|
};
|
||||||
|
|
||||||
function response(body: string, status: number, type: "json" | "xml") {
|
function response(body: string, status: number, type: "json" | "xml") {
|
||||||
return new Response(body, {
|
return new Response(body, {
|
||||||
status,
|
status,
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
// deno-lint-ignore-file ban-ts-comment
|
// deno-lint-ignore-file ban-ts-comment
|
||||||
|
import { STATUS_CODE } from "$fresh/server.ts";
|
||||||
import "jsr:@std/dotenv/load";
|
import "jsr:@std/dotenv/load";
|
||||||
import { encodeHex } from "jsr:@std/encoding/hex";
|
import { encodeHex } from "jsr:@std/encoding/hex";
|
||||||
import { getHostUrl } from "../utils.ts";
|
import { getHostUrl } from "../utils.ts";
|
||||||
import { STATUS_CODE } from "$fresh/server.ts";
|
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
clientId: Deno.env.get("VIPPS_CLIENT_ID"),
|
clientId: Deno.env.get("VIPPS_CLIENT_ID"),
|
||||||
clientSecret: Deno.env.get("VIPPS_CLIENT_SECRET"),
|
clientSecret: Deno.env.get("VIPPS_CLIENT_SECRET"),
|
||||||
ocpApimSubscriptionKeyPrimary: Deno.env.get("VIPPS_OCP_APIM_SUBSCRIPTION_KEY_PRIMARY"),
|
ocpApimSubscriptionKeyPrimary: Deno.env.get(
|
||||||
ocpApimSubscriptionKeySecondary: Deno.env.get("VIPPS_OCM_APIM_SUBSCRIPTION_KEY_SECONDARY"),
|
"VIPPS_OCP_APIM_SUBSCRIPTION_KEY_PRIMARY",
|
||||||
|
),
|
||||||
|
ocpApimSubscriptionKeySecondary: Deno.env.get(
|
||||||
|
"VIPPS_OCM_APIM_SUBSCRIPTION_KEY_SECONDARY",
|
||||||
|
),
|
||||||
msn: Deno.env.get("VIPPS_MSN"),
|
msn: Deno.env.get("VIPPS_MSN"),
|
||||||
baseUrl: Deno.env.get("VIPPS_API_BASE_URL"),
|
baseUrl: Deno.env.get("VIPPS_API_BASE_URL"),
|
||||||
};
|
};
|
||||||
|
|
@ -33,8 +37,8 @@ const getAccessToken = async function () {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
headers: {
|
headers: {
|
||||||
"client_id": config.clientId,
|
client_id: config.clientId,
|
||||||
"client_secret": config.clientSecret,
|
client_secret: config.clientSecret,
|
||||||
...standardVippsHeaders,
|
...standardVippsHeaders,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -46,6 +50,11 @@ const getAccessToken = async function () {
|
||||||
export const createAgreement = async function (email: string) {
|
export const createAgreement = async function (email: string) {
|
||||||
const token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
const amount = 5000; // 50 NOK
|
const amount = 5000; // 50 NOK
|
||||||
|
|
||||||
|
// in case of special characters, like sub-addresses added
|
||||||
|
// with +, e.g. "name+subscriptions@domain.com"
|
||||||
|
const urlEncodedEmail = encodeURIComponent(email);
|
||||||
|
const redirectUrl = `${getHostUrl()}/donations-success?urlEncodedEmail=${urlEncodedEmail}`;
|
||||||
const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/`, {
|
const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|
@ -56,23 +65,23 @@ export const createAgreement = async function (email: string) {
|
||||||
...standardVippsHeaders,
|
...standardVippsHeaders,
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
"interval": {
|
interval: {
|
||||||
"unit": "MONTH",
|
unit: "MONTH",
|
||||||
"count": 1,
|
count: 1,
|
||||||
},
|
},
|
||||||
"initialCharge": {
|
initialCharge: {
|
||||||
"amount": amount,
|
amount: amount,
|
||||||
"description": "Initial charge",
|
description: "Initial charge",
|
||||||
"transactionType": "DIRECT_CAPTURE",
|
transactionType: "DIRECT_CAPTURE",
|
||||||
},
|
},
|
||||||
"pricing": {
|
pricing: {
|
||||||
"amount": amount,
|
amount: amount,
|
||||||
"currency": "NOK",
|
currency: "NOK",
|
||||||
},
|
},
|
||||||
// email is used to identify the user in the success page
|
// email is used to identify the user in the success page
|
||||||
"merchantRedirectUrl": `${getHostUrl()}/donations-success?email=${email}`,
|
merchantRedirectUrl: redirectUrl,
|
||||||
"merchantAgreementUrl": `${getHostUrl()}`,
|
merchantAgreementUrl: `${getHostUrl()}`,
|
||||||
"productName": "Månedlig støtte til NRSS",
|
productName: "Månedlig støtte til NRSS",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -89,15 +98,23 @@ export const createAgreement = async function (email: string) {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAgreement = async function (agreementId: string) {
|
export const getAgreement = async function (agreementId: string): Promise<
|
||||||
|
| {
|
||||||
|
status: "PENDING" | "ACTIVE" | "STOPPED" | "EXPIRED";
|
||||||
|
}
|
||||||
|
| Error
|
||||||
|
> {
|
||||||
const token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, {
|
const response = await fetch(
|
||||||
// @ts-ignore
|
`${config.baseUrl}/recurring/v3/agreements/${agreementId}`,
|
||||||
headers: {
|
{
|
||||||
authorization: `Bearer ${token}`,
|
// @ts-ignore
|
||||||
...standardVippsHeaders,
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
...standardVippsHeaders,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
|
|
||||||
if (response.status === STATUS_CODE.OK) {
|
if (response.status === STATUS_CODE.OK) {
|
||||||
return response.json();
|
return response.json();
|
||||||
|
|
@ -110,18 +127,21 @@ export const getAgreement = async function (agreementId: string) {
|
||||||
|
|
||||||
export const cancelAgreement = async function (agreementId: string) {
|
export const cancelAgreement = async function (agreementId: string) {
|
||||||
const token = await getAccessToken();
|
const token = await getAccessToken();
|
||||||
const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, {
|
const response = await fetch(
|
||||||
method: "PATCH",
|
`${config.baseUrl}/recurring/v3/agreements/${agreementId}`,
|
||||||
// @ts-ignore
|
{
|
||||||
headers: {
|
method: "PATCH",
|
||||||
authorization: `Bearer ${token}`,
|
// @ts-ignore
|
||||||
"Idempotency-Key": `${agreementId}-${Date.now()}`,
|
headers: {
|
||||||
...standardVippsHeaders,
|
authorization: `Bearer ${token}`,
|
||||||
|
"Idempotency-Key": `${agreementId}-${Date.now()}`,
|
||||||
|
...standardVippsHeaders,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: "STOPPED",
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
);
|
||||||
"status": "STOPPED",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === STATUS_CODE.NoContent) {
|
if (response.status === STATUS_CODE.NoContent) {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,55 @@
|
||||||
import { FreshContext, STATUS_CODE } from "$fresh/server.ts";
|
import { FreshContext, STATUS_CODE } from "$fresh/server.ts";
|
||||||
import { caching } from "../../../lib/caching.ts";
|
import { caching } from "../../../lib/caching.ts";
|
||||||
import { rss } from "../../../lib/rss.ts";
|
import { rss } from "../../../lib/rss.ts";
|
||||||
import { responseJSON, responseXML } from "../../../lib/utils.ts";
|
import { responseJSON, responseXML, withExpiry } from "../../../lib/utils.ts";
|
||||||
|
|
||||||
export const handler = async (_req: Request, ctx: FreshContext): Promise<Response> => {
|
const FEED_CACHE_KEY = "feeds-cache";
|
||||||
|
|
||||||
|
// Deno's cache API does not respect these headers.
|
||||||
|
// Until it does, manual expiry handling is needed.
|
||||||
|
// When it's working, this function would not be needed.
|
||||||
|
// See: https://github.com/denoland/deno/issues/25795
|
||||||
|
function isExpired(headers: Headers) {
|
||||||
|
const expiresHeader = headers.get("Expires");
|
||||||
|
if (!expiresHeader) {
|
||||||
|
console.log("No Expires header found, treating as expired");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const expiryDate = new Date(expiresHeader);
|
||||||
|
const expired = now > expiryDate;
|
||||||
|
return expired;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const handler = async (
|
||||||
|
request: Request,
|
||||||
|
ctx: FreshContext,
|
||||||
|
): Promise<Response> => {
|
||||||
const seriesId = ctx.params.seriesId;
|
const seriesId = ctx.params.seriesId;
|
||||||
|
|
||||||
|
const cache = await caches.open(FEED_CACHE_KEY);
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
|
|
||||||
|
if (cachedResponse && !isExpired(cachedResponse.headers)) {
|
||||||
|
console.log(`Cache hit for feed ${seriesId}`);
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Fetching feed for ${seriesId}`);
|
||||||
const series = await caching.getSeries({ id: seriesId });
|
const series = await caching.getSeries({ id: seriesId });
|
||||||
if (!series) {
|
if (!series) {
|
||||||
return responseJSON({ message: "Series not found" }, STATUS_CODE.NotFound);
|
return responseJSON({ message: "Series not found" }, STATUS_CODE.NotFound);
|
||||||
}
|
}
|
||||||
|
|
||||||
const feed = rss.assembleFeed(series);
|
const feed = rss.assembleFeed(series);
|
||||||
|
|
||||||
const xml = responseXML(feed, STATUS_CODE.OK);
|
const response = withExpiry(
|
||||||
|
responseXML(feed, STATUS_CODE.OK),
|
||||||
|
// 2 hours
|
||||||
|
2 * 60 * 60,
|
||||||
|
);
|
||||||
|
|
||||||
return xml;
|
await cache.put(request, response.clone());
|
||||||
|
return response;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { getHostUrl, validateEmail } from "../../../lib/utils.ts";
|
import { getHostUrl, validateEmail } from "../../../lib/utils.ts";
|
||||||
import { storage } from "../../../lib/storage.ts";
|
import { storage } from "../../../lib/storage.ts";
|
||||||
import { createAgreement } from "../../../lib/vipps/vipps.ts";
|
import { createAgreement } from "../../../lib/vipps/vipps.ts";
|
||||||
|
import { getAgreement } from "../../../lib/vipps/vipps.ts";
|
||||||
|
|
||||||
export const handler = async function (req: Request): Promise<Response> {
|
export const handler = async function (req: Request): Promise<Response> {
|
||||||
const email = new URLSearchParams(req.url.split("?")[1] || "").get("email");
|
const email = new URLSearchParams(req.url.split("?")[1] || "").get("email");
|
||||||
|
|
@ -21,11 +22,27 @@ export const handler = async function (req: Request): Promise<Response> {
|
||||||
const hasActiveAgreement = existingAgreement &&
|
const hasActiveAgreement = existingAgreement &&
|
||||||
existingAgreement.validAt !== null &&
|
existingAgreement.validAt !== null &&
|
||||||
existingAgreement.revokedAt === null;
|
existingAgreement.revokedAt === null;
|
||||||
|
|
||||||
|
const agreementInVipps = existingAgreement && (await getAgreement(existingAgreement.agreementId));
|
||||||
|
|
||||||
if (
|
if (
|
||||||
|
agreementInVipps &&
|
||||||
|
!(agreementInVipps instanceof Error) &&
|
||||||
|
agreementInVipps.status !== "ACTIVE" &&
|
||||||
hasActiveAgreement
|
hasActiveAgreement
|
||||||
) {
|
) {
|
||||||
console.error("Agreement already exists", email);
|
// Somehow the Vipps agreement is not active in our system anymore.
|
||||||
return Response.redirect(errorPage);
|
// We should reflect the new status in our system.
|
||||||
|
await storage.writeVippsAgreement({
|
||||||
|
...existingAgreement,
|
||||||
|
revokedAt: new Date(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// i.e. the agreement in our system was in sync with Vipps.
|
||||||
|
if (hasActiveAgreement) {
|
||||||
|
console.error("Agreement already exists", email);
|
||||||
|
return Response.redirect(errorPage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const agreement = await createAgreement(email);
|
const agreement = await createAgreement(email);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ import { validateEmail } from "../lib/utils.ts";
|
||||||
export const handler: Handlers = {
|
export const handler: Handlers = {
|
||||||
async GET(request, ctx) {
|
async GET(request, ctx) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const email = url.searchParams.get("email");
|
const urlEncodedEmail = url.searchParams.get("urlEncodedEmail");
|
||||||
|
const email = urlEncodedEmail ? decodeURIComponent(urlEncodedEmail) : null;
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
console.error("Missing email in query params", request.url);
|
console.error("Missing email in query params", request.url);
|
||||||
return Response.redirect("/donations-error");
|
return Response.redirect("/donations-error");
|
||||||
|
|
@ -34,9 +36,7 @@ export const handler: Handlers = {
|
||||||
export default function () {
|
export default function () {
|
||||||
return (
|
return (
|
||||||
<div className="my-16 text-center flex flex-col items-center min-h-screen ">
|
<div className="my-16 text-center flex flex-col items-center min-h-screen ">
|
||||||
<h1 className="text-6xl mb-8">
|
<h1 className="text-6xl mb-8">🙏🌟😊💖</h1>
|
||||||
🙏🌟😊💖
|
|
||||||
</h1>
|
|
||||||
<p className="text-4xl mb-4">
|
<p className="text-4xl mb-4">
|
||||||
Tusen takk! Jeg er veldig takknemlig for all støtte.
|
Tusen takk! Jeg er veldig takknemlig for all støtte.
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue