Files
feeddeck/supabase/functions/_shared/feed/github.ts
Rico Berger 4a8776ee31 [core] Improve Media Handling (#5)
Improve the media handling within the app. We do not save the media
files for items to the Supabase storage anymore. The source icons are
now only saved in the Supabase storage, the usage of an url as source
icon is not possible anymore.

The media files for items are directly retrieved from the corresponding
url or for the web version from the "image-proxy-v1" Supabase function.
If the image is retireved from the Supabase function we cache the image
in the browser via the cache control headers. If the image is directly
retrieved from it's url it's cached by the "CachedNetworkImage" widget.
2023-09-08 22:08:43 +02:00

784 lines
25 KiB
TypeScript

import { SupabaseClient } from "@supabase/supabase-js";
import { Md5 } from "std/md5";
import { Redis } from "redis";
import { ISource } from "../models/source.ts";
import { IItem } from "../models/item.ts";
import { IProfile } from "../models/profile.ts";
import { decrypt } from "../utils/encrypt.ts";
import { fetchWithTimeout } from "../utils/fetchWithTimeout.ts";
import { uploadSourceIcon } from "./utils/uploadFile.ts";
export const getGithubFeed = async (
supabaseClient: SupabaseClient,
_redisClient: Redis | undefined,
profile: IProfile,
source: ISource,
): Promise<{ source: ISource; items: IItem[] }> => {
if (!source.options?.github || !source.options?.github.type) {
throw new Error("Invalid source options");
}
if (!profile.accountGithub?.token) {
throw new Error("GitHub token is missing");
}
const token = await decrypt(profile.accountGithub.token);
if (
source.options.github.type === "notifications" ||
source.options.github.type === "repositorynotifications"
) {
/**
* With `notifications` and `repositorynotifications` type users can add there GitHub notifications or repository
* notifications as source to FeedDeck. The notifications are retrieved from the GitHub API via the list
* notifications endpoint.
*
* - https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#list-notifications-for-the-authenticated-user
* - https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#list-repository-notifications-for-the-authenticated-user
* - https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#about-notification-reasons
*/
const notifications = [];
if (source.options.github.type === "notifications") {
const tmpNotifications = await request("/notifications", {
token: token,
params: {
all: "true",
participating: source.options.github.participating ? "true" : "false",
page: "1",
per_page: "50",
},
});
const user = await request("/user", {
token: token,
});
source.id =
`github-${source.userId}-${source.columnId}-${source.options.github.type}-${source.options.github.participating}`;
source.title = user.login;
source.icon = user.avatar_url;
source.icon = await uploadSourceIcon(supabaseClient, source);
notifications.push(...tmpNotifications);
} else if (
source.options.github.type === "repositorynotifications" &&
source.options.github.repository
) {
const [owner, repo] = source.options.github.repository.split("/");
const tmpNotifications = await request(
`/repos/${owner}/${repo}/notifications`,
{
token: token,
params: {
all: "true",
participating: source.options.github.participating
? "true"
: "false",
page: "1",
per_page: "50",
},
},
);
source.id =
`github-${source.userId}-${source.columnId}-${source.options.github.type}--${source.options.github.participating}-${source.options.github.repository}`;
source.title = `${owner}/${repo}`;
if (
tmpNotifications.length > 0 &&
tmpNotifications[0].repository?.owner?.avatar_url
) {
source.icon = tmpNotifications[0].repository.owner.avatar_url;
source.icon = await uploadSourceIcon(supabaseClient, source);
} else {
source.icon = `https://github.com/${owner}.png`;
source.icon = await uploadSourceIcon(supabaseClient, source);
}
notifications.push(...tmpNotifications);
} else {
throw new Error("Invalid source options");
}
source.type = "github";
source.link = "https://github.com/notifications";
const items: IItem[] = [];
for (const [_, notification] of notifications.entries()) {
items.push({
id: `${source.id}-${notification.id}`,
userId: source.userId,
columnId: source.columnId,
sourceId: source.id,
title: notification.subject?.title ?? "Notification",
link: notification.subject?.url
? `https://github.com/${notification.subject?.url.replace(
"https://api.github.com/repos/",
"",
)}`
: "",
media: notification.repository.owner.avatar_url,
description: formatDescription(notification),
author: notification.repository?.full_name,
publishedAt: Math.floor(
new Date(notification.updated_at).getTime() / 1000,
),
});
}
return { source, items };
} else if (
source.options.github.type === "useractivities" ||
source.options.github.type === "repositoryactivities" ||
source.options.github.type === "organizationactivitiespublic" ||
source.options.github.type === "organizationactivitiesprivate"
) {
/**
* `useractivities`, `repositoryactivities`, `organizationactivitiespublic` and `organizationactivitiesprivate` lets
* a user add the user, repository or organization notifications as source. We are using the corresponding events
* endpoint to get the notifications and add the id, title, icon and link to the source. Then we are going though
* all the events and adding the actor of an event as author. The title, description and link is different for each
* notification type and generated via the `formatEvent` function.
*
* - https://docs.github.com/en/developers/webhooks-and-events/events/github-event-types
* - GitHubTypeUserActivities: https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#list-events-received-by-the-authenticated-user
* - GitHubTypeRepositoryActivities: https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#list-organization-events-for-the-authenticated-user
* - GitHubTypeOrganizationActivitiesPublic: https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#list-public-organization-events
* - GitHubTypeOrganizationActivitiesPrivate: https://docs.github.com/en/rest/activity/events?apiVersion=2022-11-28#list-organization-events-for-the-authenticated-user
*/
const events = [];
if (
source.options.github.type === "useractivities" &&
source.options.github.user
) {
const tmpEvents = await request(
`/users/${source.options.github.user}/received_events/public`,
{
token: token,
params: {
page: "1",
per_page: "100",
},
},
);
const user = await request(`/users/${source.options.github.user}`, {
token: token,
});
source.id =
`github-${source.userId}-${source.columnId}-${source.options.github.type}-${source.options.github.user}`;
source.title = source.options.github.user;
source.icon = user.avatar_url;
source.icon = await uploadSourceIcon(supabaseClient, source);
source.link = `https://github.com/${source.options.github.user}`;
events.push(...tmpEvents);
} else if (
source.options.github.type === "repositoryactivities" &&
source.options.github.repository
) {
const [owner, repo] = source.options.github.repository.split("/");
const tmpEvents = await request(
`/repos/${owner}/${repo}/events`,
{
token: token,
params: {
page: "1",
per_page: "100",
},
},
);
const user = await request(`/users/${owner}`, {
token: token,
});
source.id =
`github-${source.userId}-${source.columnId}-${source.options.github.type}-${owner}-${repo}`;
source.title = `${owner}/${repo}`;
source.icon = user.avatar_url;
source.icon = await uploadSourceIcon(supabaseClient, source);
source.link = `https://github.com/${owner}/${repo}`;
events.push(...tmpEvents);
} else if (
source.options.github.type === "organizationactivitiespublic" &&
source.options.github.organization
) {
const tmpEvents = await request(
`/orgs/${source.options.github.organization}/events`,
{
token: token,
params: {
page: "1",
per_page: "100",
},
},
);
const user = await request(
`/users/${source.options.github.organization}`,
{
token: token,
},
);
source.id =
`github-${source.userId}-${source.columnId}-${source.options.github.type}-${source.options.github.organization}`;
source.title = source.options.github.organization;
source.icon = user.avatar_url;
source.icon = await uploadSourceIcon(supabaseClient, source);
source.link = `https://github.com/${source.options.github.organization}`;
events.push(...tmpEvents);
} else if (
source.options.github.type === "organizationactivitiesprivate" &&
source.options.github.organization
) {
const user = await request("/user", {
token: token,
});
const tmpEvents = await request(
`/users/${user.login}/events/orgs/${source.options.github.organization}`,
{
token: token,
params: {
page: "1",
per_page: "100",
},
},
);
const org = await request(
`/users/${source.options.github.organization}`,
{
token: token,
},
);
source.id =
`github-${source.userId}-${source.columnId}-${source.options.github.type}-${source.options.github.organization}`;
source.title = source.options.github.organization;
source.icon = org.avatar_url;
source.icon = await uploadSourceIcon(supabaseClient, source);
source.link = `https://github.com/${source.options.github.organization}`;
events.push(...tmpEvents);
} else {
throw new Error("Invalid source options");
}
source.type = "github";
const items: IItem[] = [];
for (const [_, event] of events.entries()) {
const eventDetails = formatEvent(event);
if (eventDetails) {
items.push({
id: `${source.id}-${event.id}`,
userId: source.userId,
columnId: source.columnId,
sourceId: source.id,
title: eventDetails.title ?? "",
link: eventDetails.link ?? "",
media: event.actor?.avatar_url
? event.actor?.avatar_url
: event.actor?.login
? `https://github.com/${event.actor.login}.png`
: undefined,
description: eventDetails.description,
author: event.actor?.login ? event.actor.login : undefined,
publishedAt: Math.floor(
new Date(event.created_at).getTime() / 1000,
),
});
}
}
return { source, items };
} else if (
source.options.github.type === "searchissuesandpullrequests" &&
source.options.github.query
) {
/**
* The `searchissuesandpullrequests` let a user add a seach query as source. With this type it is possible to follow
* all issues and pull requests of an user, repository or organization. Since we allow a custom query we do not add
* a icon and link to the source. The id of the source is generated based on a hash of the query. Then we are going
* through all the returned issues and generating an item for each issue, where we are using the user as author (we
* were also thinking about using the repository, but decided against it).
*
* - https://docs.github.com/en/rest/search?apiVersion=2022-11-28#search-issues-and-pull-requests
*/
const issues = await request(
`/search/issues`,
{
token: token,
params: {
q: source.options.github.query,
sort: "created",
direction: "desc",
page: "1",
per_page: "100",
},
},
);
source.id =
`github-${source.userId}-${source.columnId}-${source.options.github.type}-${
new Md5().update(source.options.github.query).toString()
}`;
source.type = "github";
source.title = source.options.github.queryName || "Search";
source.icon = undefined;
source.link = undefined;
const items: IItem[] = [];
for (const [_, issue] of issues.items.entries()) {
items.push({
id: `${source.id}-${issue.node_id}`,
userId: source.userId,
columnId: source.columnId,
sourceId: source.id,
title: issue.title,
link: issue.html_url,
media: issue?.user?.avatar_url
? issue.user.avatar_url
: issue?.user?.login
? `https://github.com/${issue.user.login}.png`
: undefined,
description: `${
issue.repository_url.replace(
"https://api.github.com/repos/",
"",
)
} #${issue.number}`,
author: issue.user.login,
publishedAt: Math.floor(
new Date(issue.created_at).getTime() / 1000,
),
});
}
return { source, items };
}
throw new Error("Invalid source options");
};
/**
* `request` is a helper function to make a request to the GitHub API.
*/
const request = async (
url: string,
options: { token: string; params?: Record<string, string> },
) => {
const res = await fetchWithTimeout(
`https://api.github.com${url}${
options.params ? `?${new URLSearchParams(options.params).toString()}` : ""
}`,
{
method: "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${options.token}`,
"X-GitHub-Api-Version": "2022-11-28",
},
},
5000,
);
if (!res.ok) {
const error = await res.json();
throw new Error(error?.message ?? "Unknown error");
}
return await res.json();
};
/**
* `formatDescription` formats the description for a notification based on the notification reason.
* See: https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#about-notification-reasons
*/
// deno-lint-ignore no-explicit-any
const formatDescription = (notification: any): string | undefined => {
switch (notification.reason) {
case "assign":
return "You were assigned to the issue.";
case "author":
return "You created the thread.";
case "comment":
return "You commented on the thread.";
case "ci_activity":
return "A GitHub Actions workflow run that you triggered was completed.";
case "invitation":
return "You accepted an invitation to contribute to the repository.";
case "manual":
return "You subscribed to the thread (via an issue or pull request).";
case "mention":
return "You were specifically @mentioned in the content.";
case "review_requested":
return "You, or a team you're a member of, were requested to review a pull request.";
case "security_alert":
return "GitHub discovered a security vulnerability in your repository.";
case "state_change":
return "You changed the thread state (for example, closing an issue or merging a pull request).";
case "subscribed":
return "You're watching the repository.";
case "team_mention":
return "You were on a team that was mentioned.";
default:
return undefined;
}
};
/**
* `formatEvent` formats the given event by returning a proper title, link and description for a item as we save it in
* our database. If the event type is not supported or a required field is missing we return an error.
*/
const formatEvent = (
// deno-lint-ignore no-explicit-any
event: any,
): {
title: string | undefined;
link: string | undefined;
description: string | undefined;
} | undefined => {
switch (event.type) {
case "CommitCommentEvent":
if (event.payload?.comment?.html_url) {
return {
title: "",
link: event.payload.comment.html_url,
description: "Added a comment to a commit",
};
}
return undefined;
case "CreateEvent":
if (event.payload?.ref_type) {
let description = "";
switch (event.payload?.ref_type) {
case "repository":
description = "Created a new repository";
break;
case "branch":
description = "Created a new branch";
break;
case "tag":
description = "Created a new tag";
break;
default:
description = "Created something";
break;
}
return {
title: "",
link: event.repo.html_url,
description: description,
};
}
return undefined;
case "DeleteEvent":
if (event.payload?.ref_type) {
let description = "";
switch (event.payload?.ref_type) {
case "repository":
description = "Deleted a repository";
break;
case "branch":
description = "Deleted a branch";
break;
case "tag":
description = "Deleted a tag";
break;
default:
description = "Deleted something";
break;
}
return {
title: "",
link: event.repo.html_url,
description: description,
};
}
return undefined;
case "ForkEvent":
if (event.payload?.forkee) {
return {
title: event.payload.forkee.name,
link: event.payload.forkee.html_url,
description: "Forked a repository",
};
}
return undefined;
case "GollumEvent":
if (event.payload?.pages && event.payload?.pages.length > 0) {
let description = "";
switch (event.payload?.pages[0].action) {
case "created":
description = "Created a new wiki page";
break;
default:
description = "Updated a wiki page";
break;
}
return {
title: event.payload.pages[0].title,
link: event.payload.pages[0].html_url,
description: description,
};
}
return undefined;
case "IssueCommentEvent":
if (event.payload?.issue && event.payload?.comment) {
return {
title: event.payload.issue.title,
link: event.payload.comment.html_url,
description: "Added a comment",
};
}
return undefined;
case "IssuesEvent":
if (event.payload?.action && event.payload?.issue) {
let description = "";
switch (event.payload?.action) {
case "assigned":
description = "Assigned";
break;
case "unassigned":
description = "Unassigned";
break;
case "review_requested":
description = "Requested a review";
break;
case "review_request_removed":
description = "Removed a requested review";
break;
case "labeled":
description = "Added a label";
break;
case "unlabeled":
description = "Removed a label";
break;
case "opened":
description = "Opened an issue";
if (event.payload.issue.pull_request) {
description = "Opened a pull request";
}
break;
case "closed":
description = "Closed an issue";
if (event.payload.issue.pull_request) {
description = "Closed a pull request";
}
break;
case "reopened":
description = "Reopened an issue";
if (event.payload.issue.pull_request) {
description = "Reopened a pull request";
}
break;
case "synchronize":
description = "Synchronized an issue";
if (event.payload.issue.pull_request) {
description = "Synchronized a pull request";
}
break;
case "edited":
description = "Edited an issue";
if (event.payload.issue.pull_request) {
description = "Edited a pull request";
}
break;
}
return {
title: event.payload.issue.title,
link: event.payload.issue.html_url,
description: description,
};
}
return undefined;
case "MemberEvent":
if (event.payload?.action && event.payload?.member) {
let description = "";
switch (event.payload?.action) {
case "added":
description = "Was added as member";
break;
}
return {
title: event.payload.member.login,
link: event.payload.member.html_url,
description: description,
};
}
return undefined;
case "PullRequestEvent":
if (event.payload?.action && event.payload?.pull_request) {
let description = "";
switch (event.payload?.action) {
case "assigned":
description = "Assigned";
break;
case "unassigned":
description = "Unassigned";
break;
case "review_requested":
description = "Requested a review";
break;
case "review_request_removed":
description = "Removed a requested review";
break;
case "labeled":
description = "Added a label";
break;
case "unlabeled":
description = "Removed a label";
break;
case "opened":
description = "Opened";
break;
case "closed":
if (
event.payload?.pull_request.merged &&
event.payload?.pull_request.merged == true
) {
description = "Merged";
break;
} else {
description = "Closed";
break;
}
case "reopened":
description = "Reopened";
break;
case "synchronize":
description = "Synchronized";
break;
case "edited":
description = "Edited";
break;
}
return {
title: event.payload.pull_request.title,
link: event.payload.pull_request.html_url,
description: description,
};
}
return undefined;
case "PullRequestReviewEvent":
if (
event.payload?.pull_request &&
event.payload?.review
) {
return {
title: event.payload.pull_request.title,
link: event.payload.review.html_url,
description: "Added a review",
};
}
return undefined;
case "PullRequestReviewCommentEvent":
if (
event.payload?.action &&
event.payload?.pull_request &&
event.payload?.comment
) {
let description = "";
switch (event.payload?.action) {
case "created":
description = "Added a review comment";
break;
case "edited":
description = "Updated a review comment";
break;
case "deleted":
description = "Deleted a review comment";
break;
}
return {
title: event.payload.pull_request.title,
link: event.payload.comment.html_url,
description: description,
};
}
return undefined;
case "PushEvent":
if (event.payload?.repository) {
return {
title: "",
link: event.payload.repository.html_url,
description: event.payload.commits
? event.payload.commits.length === 1
? `Pushed ${event.payload.commits.length} commit`
: `Pushed ${event.payload.commits.length} commits`
: "",
};
}
return undefined;
case "ReleaseEvent":
if (event.payload?.action && event.payload?.release) {
let description = "";
switch (event.payload?.action) {
case "created":
description = "Release was created";
break;
case "deleted":
description = "Release was created";
break;
case "edited":
description = "Release was updated";
break;
case "prereleased":
description = "Prerelease was created";
break;
case "published":
description = "Release was published";
break;
case "released":
description = "Release was released";
break;
case "unpublished":
description = "Release was unpublished";
break;
}
return {
title: event.payload.release.name,
link: event.payload.release.html_url,
description: description,
};
}
return undefined;
case "WatchEvent":
if (event.actor) {
return {
title: "",
link: `https://github.com/${event.actor.login}`,
description: `${event.actor.login} starred ${
event.payload?.repository?.name || "the repository"
}`,
};
}
return undefined;
default:
return undefined;
}
};