diff --git a/components/Button.tsx b/components/Button.tsx index 28d4ad6..6c5aeb1 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -4,7 +4,7 @@ type ButtonProps = { children: ComponentChildren; } & JSX.HTMLAttributes; -const className = "p-2 border(gray-100 2) hover:bg-gray-200 bg-blue-200 w-max font-medium"; +const className = "p-2 border(gray-100 2) hover:bg-gray-200 bg-blue-200 font-medium"; export function Button(props: ButtonProps) { const { children, className: additionalClass = "", ...buttonProps } = props; diff --git a/components/Footer.tsx b/components/Footer.tsx index 868f185..889bc75 100644 --- a/components/Footer.tsx +++ b/components/Footer.tsx @@ -2,8 +2,13 @@ import config from "../deno.json" with { type: "json" }; export default function Footer() { return ( -
- Kildekode +
+
+ Kildekode +

+ Krets AS - 922 739 625 +

+
); } diff --git a/components/Input.tsx b/components/Input.tsx new file mode 100644 index 0000000..ea652c7 --- /dev/null +++ b/components/Input.tsx @@ -0,0 +1,12 @@ +import { JSX } from "preact"; + +export const Input = ( + props: JSX.HTMLAttributes, +) => { + return ( + + ); +}; diff --git a/components/Search.tsx b/components/Search.tsx index da0863a..4db4afd 100644 --- a/components/Search.tsx +++ b/components/Search.tsx @@ -1,11 +1,12 @@ +import { Input } from "./Input.tsx"; + export default function Search(props: { defaultValue: string | null }) { return (
- (""); + const [emailValid, setEmailValid] = useState(false); + + useEffect(() => { + const valid = validateEmail(emailInput); + setEmailValid(!!valid); + }, [emailInput]); + + return ( + <> + +
+

+ Økonomisk Støtte +

+

+ Jeg ønsker at NRSS skal være gratis tilgjengelig for alle. Imidlertid koster det penger og tid å drifte og + vedlikeholde en nettside. Dersom du har råd til det (og bare da!) setter jeg stor pris på om du vil støtte + prosjektet med et lite, månedlig beløp via Vipps. +

+ +
+ + { + setEmailInput(e.currentTarget.value); + }} + > + + { + if (!emailValid) { + e.preventDefault(); + } + }} + > + {/* @ts-ignore */} + + {/* @ts-ignore */} + + + +

+ Eposten brukes utelukkende som referanse dersom du ønsker å avslutte støtten. Du kommer ikke til å motta + noen eposter. + + Du kan når som helst avslutte støtten fra{" "} + + denne siden + . + +

+
+ +

+ Opplever du problemer med betalingen, eller har du andre spørsmål? Ta{" "} + + kontakt på mail + . +

+
+ + ); +}; diff --git a/lib/caching.ts b/lib/caching.ts index 3ee20e1..b51afa0 100644 --- a/lib/caching.ts +++ b/lib/caching.ts @@ -10,7 +10,7 @@ async function initialFetch(options: { id: string }): Promise { if (!series) { return null; } - const stored = storage.write(series); + const stored = storage.writeSeries(series); if (!stored) { console.error(`Failed to store series ${options.id}`); return null; @@ -50,7 +50,7 @@ async function updateFetch(existingSeries: Series): Promise { - const seriesFromStorage = await storage.read(options); + const seriesFromStorage = await storage.readSeries(options); /** * We don't have the feed in storage, diff --git a/lib/rss.test.ts b/lib/rss.test.ts index f3d0532..fead2fb 100644 --- a/lib/rss.test.ts +++ b/lib/rss.test.ts @@ -25,3 +25,11 @@ Deno.test("generated rss contains all episode titles", () => { assertEquals(feed.includes(episode.title), true); }); }); + +Deno.test("generated rss contains promo with link to donations page", () => { + const series = testUtils.generateSeries(); + const feed = rss.assembleFeed(series); + + console.log(feed); + feed.includes("Vurder å støtte utviklingen via Vipps"); +}); diff --git a/lib/rss.ts b/lib/rss.ts index e02694e..24d1e7d 100644 --- a/lib/rss.ts +++ b/lib/rss.ts @@ -1,5 +1,5 @@ import { declaration, serialize, Tag, tag } from "serialize-xml"; -import { getHostName } from "./utils.ts"; +import { getHostUrl } from "./utils.ts"; import { Episode, Series } from "./storage.ts"; function assembleFeed(series: Series): string { @@ -57,8 +57,19 @@ function assembleFeed(series: Series): string { ); } +function descriptionWithDonationPromotion(description: string): string { + const promotion = + `Takk for at du bruker NRSS 🙏🌟 Vurder å støtte utviklingen via Vipps med omtrent det samme som prisen på en kaffekopp. Se mer på ${getHostUrl()}`; + + return ` + ${promotion} \n + --------------------------------\n + ${description} + `; +} + function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag { - const description = episode.subtitle || ""; + const description = descriptionWithDonationPromotion(episode.subtitle || ""); return tag("item", [ tag("title", episode.title), @@ -71,7 +82,7 @@ function assembleEpisode(episode: Episode, seriesId: Series["id"]): Tag { tag("podcast:chapters", "", [ [ "url", - `${getHostName()}/api/feeds/${seriesId}/${episode.id}/chapters`, + `${getHostUrl()}/api/feeds/${seriesId}/${episode.id}/chapters`, ], ["type", "application/json+chapters"], ]), diff --git a/lib/storage.test.ts b/lib/storage.test.ts index 602374f..214aee0 100644 --- a/lib/storage.test.ts +++ b/lib/storage.test.ts @@ -4,18 +4,18 @@ import { testUtils } from "./test-utils.ts"; Deno.test("can store a series", async () => { const series = testUtils.generateSeries(); - await storage.write(series); + await storage.writeSeries(series); - const readSeries = await storage.read(series); + const readSeries = await storage.readSeries(series); assertEquals(readSeries, series); }); Deno.test("can retrieve a series", async () => { const series = testUtils.generateSeries(); - await storage.write(series); + await storage.writeSeries(series); - const readSeries = await storage.read(series); + const readSeries = await storage.readSeries(series); assertEquals(readSeries, series); }); diff --git a/lib/storage.ts b/lib/storage.ts index 6b8e2db..55c6e18 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -18,26 +18,45 @@ export type Series = { episodes: Episode[]; }; +export type VippsAgreement = { + // the user identifier / email + id: string; + // the agreement id from Vipps + agreementId: string; + createdAt: Date; + validAt: Date | null; + revokedAt: Date | null; +}; + const kv = await Deno.openKv(); -function seriesKey(series: { id: string }) { - return ["series", series.id]; +type Identifiable = { id: string }; +type Collection = "series" | "vipps-agreements"; + +function read(collection: Collection) { + return async function (series: Identifiable) { + const key = [collection, series.id]; + const read = await kv.get(key); + return read.value as T | null; + }; } -async function read(options: { id: string }): Promise { - const { id } = options; - const key = seriesKey({ id }); - const read = await kv.get(key); - return read.value; +function write(collection: Collection) { + return async function (entity: T) { + const key = [collection, entity.id]; + const stored = await kv.set(key, entity); + return stored.ok; + }; } -async function write(series: Series): Promise { - const key = seriesKey(series); - const stored = await kv.set(key, series); - return stored.ok; -} +const readSeries = read("series"); +const writeSeries = write("series"); +const readVippsAgreement = read("vipps-agreements"); +const writeVippsAgreement = write("vipps-agreements"); export const storage = { - read, - write, + readSeries, + writeSeries, + readVippsAgreement, + writeVippsAgreement, }; diff --git a/lib/utils.ts b/lib/utils.ts index 586c836..370f50e 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,11 +1,15 @@ import { STATUS_CODE } from "$fresh/server.ts"; -export function getHostName() { +export function getHostUrl() { const deploymentId = Deno.env.get("DENO_DEPLOYMENT_ID"); + const tunnelUrl = Deno.env.get("TUNNEL_URL"); if (deploymentId) { return `https://nrss-${deploymentId}.deno.dev`; + } else if (tunnelUrl) { + console.debug(`Using tunnel URL: ${tunnelUrl}`); + return tunnelUrl; } else { - // assume env + console.debug(`Assuming localhost`); return "http://localhost:8000"; } } @@ -30,3 +34,13 @@ function response(body: string, status: number, type: "json" | "xml") { }, }); } + +export function validateEmail(input: string) { + input.match( + // https://emailregex.com/ + // deno-lint-ignore no-control-regex + /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/, + ); + + return !!input; +} diff --git a/lib/vipps/vipps.ts b/lib/vipps/vipps.ts new file mode 100644 index 0000000..47715a6 --- /dev/null +++ b/lib/vipps/vipps.ts @@ -0,0 +1,131 @@ +// deno-lint-ignore-file ban-ts-comment +import "jsr:@std/dotenv/load"; +import { getHostUrl } from "../utils.ts"; +import { STATUS_CODE } from "$fresh/server.ts"; + +const config = { + clientId: Deno.env.get("VIPPS_CLIENT_ID"), + clientSecret: Deno.env.get("VIPPS_CLIENT_SECRET"), + ocpApimSubscriptionKeyPrimary: Deno.env.get("VIPPS_OCP_APIM_SUBSCRIPTION_KEY_PRIMARY"), + ocpApimSubscriptionKeySecondary: Deno.env.get("VIPPS_OCM_APIM_SUBSCRIPTION_KEY_SECONDARY"), + msn: Deno.env.get("VIPPS_MSN"), + baseUrl: Deno.env.get("VIPPS_API_BASE_URL"), +}; + +// ensure all environment variables are set +for (const [key, value] of Object.entries(config)) { + if (!value) { + throw new Error(`Missing environment variable: ${key}`); + } +} + +const standardVippsHeaders = { + "Content-Type": "application/json", + "Ocp-Apim-Subscription-Key": config.ocpApimSubscriptionKeyPrimary, + "Merchant-Serial-Number": config.msn, + "Vipps-System-Name": "Krets AS", +} as const; + +const getAccessToken = async function () { + console.log(`Fetching ${`${config.baseUrl}/accesstoken/get`}`); + const response = await fetch(`${config.baseUrl}/accesstoken/get`, { + method: "POST", + // @ts-ignore + headers: { + "client_id": config.clientId, + "client_secret": config.clientSecret, + ...standardVippsHeaders, + }, + }); + + const data = await response.json(); + return data.access_token as string; +}; + +export const createAgreement = async function (email: string) { + const token = await getAccessToken(); + const amount = 5000; // 50 NOK + const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/`, { + method: "POST", + // @ts-ignore + headers: { + authorization: `Bearer ${token}`, + "Idempotency-Key": `${email}-${Date.now()}`, + ...standardVippsHeaders, + }, + body: JSON.stringify({ + "interval": { + "unit": "MONTH", + "count": 1, + }, + "initialCharge": { + "amount": amount, + "description": "Initial charge", + "transactionType": "DIRECT_CAPTURE", + }, + "pricing": { + "amount": amount, + "currency": "NOK", + }, + // email is used to identify the user in the success page + "merchantRedirectUrl": `${getHostUrl()}/donations-success?email=${email}`, + "merchantAgreementUrl": `${getHostUrl()}`, + "productName": "Månedlig støtte til NRSS", + }), + }); + + if (response.status === STATUS_CODE.Created) { + const body = response.json(); + return body as unknown as { + vippsConfirmationUrl: string; + agreementId: string; + }; + } else { + const errorMessage = `Failed to create Vipps agreement ${response.status} ${await response.text()}`; + console.error(errorMessage); + return new Error(errorMessage); + } +}; + +export const getAgreement = async function (agreementId: string) { + const token = await getAccessToken(); + const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, { + // @ts-ignore + headers: { + authorization: `Bearer ${token}`, + ...standardVippsHeaders, + }, + }); + + if (response.status === STATUS_CODE.OK) { + return response.json(); + } else { + const errorMessage = `Failed to get Vipps agreement ${response.status} ${await response.text()}`; + console.error(errorMessage); + return new Error(errorMessage); + } +}; + +export const cancelAgreement = async function (agreementId: string) { + const token = await getAccessToken(); + const response = await fetch(`${config.baseUrl}/recurring/v3/agreements/${agreementId}`, { + method: "PATCH", + // @ts-ignore + headers: { + authorization: `Bearer ${token}`, + "Idempotency-Key": `${agreementId}-${Date.now()}`, + ...standardVippsHeaders, + }, + body: JSON.stringify({ + "status": "STOPPED", + }), + }); + + if (response.status === STATUS_CODE.NoContent) { + return true; + } else { + const errorMessage = `Failed to cancel Vipps agreement ${response.status} ${await response.text()}`; + console.error(errorMessage); + return new Error(errorMessage); + } +}; diff --git a/routes/_app.tsx b/routes/_app.tsx index 8c4c566..81afab4 100644 --- a/routes/_app.tsx +++ b/routes/_app.tsx @@ -1,4 +1,6 @@ import { PageProps } from "$fresh/server.ts"; +import Footer from "../components/Footer.tsx"; +import Header from "../components/Header.tsx"; export default function App({ Component }: PageProps) { return ( @@ -14,7 +16,9 @@ export default function App({ Component }: PageProps) { NRSS +
+
); diff --git a/routes/api/trigger-donation/vipps.ts b/routes/api/trigger-donation/vipps.ts new file mode 100644 index 0000000..76b44e4 --- /dev/null +++ b/routes/api/trigger-donation/vipps.ts @@ -0,0 +1,43 @@ +import { getHostUrl, validateEmail } from "../../../lib/utils.ts"; +import { storage } from "../../../lib/storage.ts"; +import { createAgreement } from "../../../lib/vipps/vipps.ts"; + +export const handler = async function (req: Request): Promise { + const email = new URLSearchParams(req.url.split("?")[1] || "").get("email"); + const errorPage = `${getHostUrl()}/donations-error`; + + if (!email) { + console.error("Missing email in query params", req.url); + return Response.redirect(errorPage); + } + + const valid = validateEmail(email); + if (!valid) { + console.error("Invalid email server side", email); + return Response.redirect(errorPage); + } + + const existingAgreement = await storage.readVippsAgreement({ id: email }); + if (existingAgreement && existingAgreement.revokedAt === null) { + console.error("Agreement already exists", email); + return Response.redirect(errorPage); + } + + const agreement = await createAgreement(email); + if (agreement instanceof Error) { + console.error("Failed to create Vipps agreement", agreement); + return Response.redirect(errorPage); + } + + // associate the agreement with the user here + // so it can be updated in the success page + await storage.writeVippsAgreement({ + id: email, + agreementId: agreement.agreementId, + createdAt: new Date(), + validAt: null, + revokedAt: null, + }); + + return Response.redirect(agreement.vippsConfirmationUrl); +}; diff --git a/routes/donations-cancel.tsx b/routes/donations-cancel.tsx new file mode 100644 index 0000000..9fea969 --- /dev/null +++ b/routes/donations-cancel.tsx @@ -0,0 +1,90 @@ +import { Handlers, PageProps } from "$fresh/server.ts"; +import { Button } from "../components/Button.tsx"; +import { Input } from "../components/Input.tsx"; +import { storage } from "../lib/storage.ts"; +import * as vipps from "../lib/vipps/vipps.ts"; +import { validateEmail } from "../lib/utils.ts"; + +type Props = { + email: string | null; + error: string | null; + cancelled: boolean; +}; + +export const handler: Handlers = { + async GET(request, ctx) { + const url = new URL(request.url); + const email = url.searchParams.get("email"); + + if (!email) { + return ctx.render({ email: null, error: null, cancelled: false }); + } + + if (!validateEmail(email)) { + console.error("Ugyldig e-post server-side", email); + return ctx.render({ email: null, error: "Ugyldig e-post", cancelled: false }); + } + + const agreement = await storage.readVippsAgreement({ id: email }); + if (!agreement) { + console.error("Mangler avtale for e-post", email); + return ctx.render({ email: null, error: `Ingen abonnement funnet for "${email}"`, cancelled: false }); + } + + const vippsResponse = await vipps.cancelAgreement(agreement.agreementId); + if (vippsResponse instanceof Error) { + return ctx.render({ email, error: "Kunne ikke kansellere Vipps-abonnement", cancelled: false }); + } + await storage.writeVippsAgreement({ + ...agreement, + revokedAt: new Date(), + }); + + return ctx.render({ email, error: null, cancelled: true }); + }, +}; + +export default function ({ data }: PageProps) { + return ( +
+

+ Avslutt støtte til NRSS +

+ {data.error && ( +

+ En feil oppsto: "{data.error}". Ta gjerne{" "} + + kontakt på mail + . +

+ )} + {data.cancelled && ( +

+ Støtten er nå avsluttet.
+ Tusen takk for at du har støttet NRSS 🙏 +

+ )} + {!data.cancelled && ( +
+ + +
+ )} +
+ ); +} diff --git a/routes/donations-error.tsx b/routes/donations-error.tsx new file mode 100644 index 0000000..c50fbfd --- /dev/null +++ b/routes/donations-error.tsx @@ -0,0 +1,19 @@ +export default function () { + return ( +
+

+ 💥🔧🚨😱 +

+

+ Auda. Noe gikk galt. Det er flaut. +

+

+ Forteller du meg om feilen{" "} + + på mail + {" "} + blir jeg veldig glad. +

+
+ ); +} diff --git a/routes/donations-success.tsx b/routes/donations-success.tsx new file mode 100644 index 0000000..f57f0d9 --- /dev/null +++ b/routes/donations-success.tsx @@ -0,0 +1,50 @@ +import { Handlers } from "$fresh/server.ts"; +import { storage } from "../lib/storage.ts"; +import { validateEmail } from "../lib/utils.ts"; + +export const handler: Handlers = { + async GET(request, ctx) { + const url = new URL(request.url); + const email = url.searchParams.get("email"); + if (!email) { + console.error("Missing email in query params", request.url); + return Response.redirect("/donations-error"); + } + + if (!validateEmail(email)) { + console.error("Invalid email server side", email); + return Response.redirect("/donations-error"); + } + + const agreement = await storage.readVippsAgreement({ id: email }); + if (!agreement) { + console.error("Missing agreement for email", email); + return Response.redirect("/donations-error"); + } + + await storage.writeVippsAgreement({ + ...agreement, + validAt: new Date(), + }); + + return ctx.render({}); + }, +}; + +export default function () { + return ( +
+

+ 🙏🌟😊💖 +

+

+ Tusen takk! Jeg er veldig takknemlig for all støtte. +

+

+ + Gå tilbake til forsiden + +

+
+ ); +} diff --git a/routes/index.tsx b/routes/index.tsx index d10efbb..2872ab4 100644 --- a/routes/index.tsx +++ b/routes/index.tsx @@ -1,11 +1,10 @@ import { Head } from "$fresh/runtime.ts"; import { Handlers, PageProps } from "$fresh/server.ts"; -import Footer from "../components/Footer.tsx"; -import Header from "../components/Header.tsx"; import Search from "../components/Search.tsx"; import SeriesCard from "../components/SeriesCard.tsx"; import { CSS, render } from "$gfm"; import { nrkRadio, NrkSearchResultList } from "../lib/nrk/nrk.ts"; +import { DonationSection } from "../islands/DonationSection.tsx"; type Props = { query: string | null; @@ -43,14 +42,13 @@ export default function Home({ data, url }: PageProps) { />
-
-
);