diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml new file mode 100644 index 0000000..97b042a --- /dev/null +++ b/.github/workflows/docker-image.yml @@ -0,0 +1,24 @@ +name: Publish Docker Image + +on: [push, pull_request] + +jobs: + build_and_publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + + # Build + - name: Build image + run: | + [[ $GITHUB_REF_NAME = "main" ]] && TAG="latest" || TAG=$GITHUB_REF_NAME + TAG="${TAG//\//$'-'}" + docker build . --file Dockerfile --tag ghcr.io/"${GITHUB_REPOSITORY,,}":$TAG + + # Push + - name: Push image + run: | + [[ $GITHUB_REF_NAME = "main" ]] && TAG="latest" || TAG=$GITHUB_REF_NAME + TAG="${TAG//\//$'-'}" + echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u $GITHUB_REPOSITORY_OWNER --password-stdin + docker push ghcr.io/"${GITHUB_REPOSITORY,,}":$TAG diff --git a/Dockerfile b/Dockerfile index 6c7a766..4208351 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,4 +19,4 @@ COPY . . EXPOSE 5100 # Wait for PostgreSQL to be ready and then start the Node.js server -CMD ["wait-for-it", "postgres:5432", "--", "npm", "start"] +CMD wait-for-it $POSTGRES_HOSTNAME:5432 -- npm start diff --git a/example.env b/example.env index 329a8e5..ef39090 100644 --- a/example.env +++ b/example.env @@ -1,5 +1,8 @@ -POSTGRES_USER=user +POSTGRES_USER=postgres POSTGRES_PASSWORD=password +POSTGRES_HOSTNAME=postgres +POSTGRES_PORT=5432 +POSTGRES_DATABASE=thunder_database APNS_KEY_ID=key_id APNS_TEAM_ID=team_id diff --git a/package-lock.json b/package-lock.json index 2cf1a57..d145065 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "node-cron": "^3.0.3", "pg": "^8.11.5", "pg-hstore": "^2.3.4", - "sequelize": "^6.37.3" + "sequelize": "^6.37.3", + "undici": "^6.16.1" }, "devDependencies": { "@types/express": "^4.17.21", @@ -1865,6 +1866,14 @@ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.6.tgz", "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==" }, + "node_modules/undici": { + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.16.1.tgz", + "integrity": "sha512-NeNiTT7ixpeiL1qOIU/xTVpHpVP0svmI6PwoCKaMGaI5AsHOaRdwqU/f7Fi9eyU4u03nd5U/BC8wmRMnS9nqoA==", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/package.json b/package.json index cd9b60e..7594a06 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "node-cron": "^3.0.3", "pg": "^8.11.5", "pg-hstore": "^2.3.4", - "sequelize": "^6.37.3" + "sequelize": "^6.37.3", + "undici": "^6.16.1" }, "devDependencies": { "@types/express": "^4.17.21", diff --git a/src/database/database.ts b/src/database/database.ts index 88d91a6..f6ec59e 100644 --- a/src/database/database.ts +++ b/src/database/database.ts @@ -6,7 +6,7 @@ dotenv.config(); // Configure database const sequelize = new Sequelize( - `postgres://${process.env.POSTGRES_USER}:${process.env.POSTGRES_PASSWORD}@postgres:5432/database` + `postgres://${process.env.POSTGRES_USER || "postgres"}:${process.env.POSTGRES_PASSWORD || "password"}@${process.env.POSTGRES_HOSTNAME || "postgres"}:${process.env.POSTGRES_PORT || "5432"}/${process.env.POSTGRES_DATABASE || "thunder_database"}` ); export default sequelize; diff --git a/src/database/models/account_notification.ts b/src/database/models/account_notification.ts index e68cf24..80d8cb5 100644 --- a/src/database/models/account_notification.ts +++ b/src/database/models/account_notification.ts @@ -35,7 +35,12 @@ AccountNotification.init( type: DataTypes.DATE, allowNull: false, defaultValue: DataTypes.NOW, - } + }, + testQueued: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: false + }, }, { sequelize } ); diff --git a/src/index.ts b/src/index.ts index 48be233..dcb07ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,10 +11,10 @@ import { import sequelize from "./database/database"; // Helper functions -import { addAccountNotification } from "./notifications/notifications"; +import { addAccountNotification, generateTestNotification } from "./notifications/notifications"; // Start cron job -import notificationService from "./notifications/service/notification_service"; +import { notificationService, checkNotifications } from "./notifications/service/notification_service"; import AccountNotification from "./database/models/account_notification"; notificationService.start(); @@ -66,6 +66,25 @@ app.delete("/notifications", (req, res) => { } }); +app.post("/test", async (req: Request, res: Response) => { + const { jwt } = req.body as NotificationRequest; + + if (!jwt) + return res.status(400).send("Missing one or more required parameters"); + + try { + let result = await generateTestNotification(jwt); + + res.status(201).json(result); + + // Do a check right away + checkNotifications(); + } catch (error) { + console.error("Error creating test notification:", error); + res.status(500).json({ error: "Internal Server Error" }); + } +}); + // Start the server and connect to the database app.listen(port, async () => { console.log(`Server is running on http://localhost:${port}`); diff --git a/src/notifications/apns/apns.ts b/src/notifications/apns/apns.ts index 6f59065..29accb1 100644 --- a/src/notifications/apns/apns.ts +++ b/src/notifications/apns/apns.ts @@ -6,16 +6,22 @@ import { PrivateMessageView, } from "lemmy-js-client"; -const options = { - token: { - key: "src/notifications/apns/apns.p8", - keyId: process.env.APNS_KEY_ID as string, - teamId: process.env.APNS_TEAM_ID as string, - }, - production: false, -}; - -const provider = new apn.Provider(options); +let provider : apn.Provider; +if (process.env.APNS_KEY_ID && process.env.APNS_TEAM_ID) { + const options = { + token: { + key: "src/notifications/apns/apns.p8", + keyId: process.env.APNS_KEY_ID as string, + teamId: process.env.APNS_TEAM_ID as string, + }, + production: false, + }; + + provider = new apn.Provider(options); +} +else { + console.warn("APN Key ID or Team ID is empty; not initializing APN service."); +} /** * Creates the template for the notification to be sent to APNs. diff --git a/src/notifications/notifications.ts b/src/notifications/notifications.ts index efa477b..e653bf1 100644 --- a/src/notifications/notifications.ts +++ b/src/notifications/notifications.ts @@ -68,4 +68,10 @@ async function deleteAccountNotification( return await AccountNotification.destroy({ where: { token } }); } -export { addAccountNotification, getAccountNotification, deleteAccountNotification }; +async function generateTestNotification( + jwt: string | undefined, +): Promise<[affectedCount: number]> { + return AccountNotification.update({ testQueued: true }, { where: { jwt } }); +} + +export { addAccountNotification, getAccountNotification, deleteAccountNotification, generateTestNotification }; diff --git a/src/notifications/service/notification_service.ts b/src/notifications/service/notification_service.ts index 5089f94..a8837bb 100644 --- a/src/notifications/service/notification_service.ts +++ b/src/notifications/service/notification_service.ts @@ -2,10 +2,11 @@ import cron from "node-cron"; import { Op } from "sequelize"; import { isAfter, subMinutes } from "date-fns"; import { LemmyHttp } from "lemmy-js-client"; +import { Sequelize } from "sequelize"; import AccountNotification from "../../database/models/account_notification"; import { provider, createAPNSNotification } from "../apns/apns"; -import { createUnifiedPushNotification, sendUnifiedPushNotification } from "../unifiedpush/unifiedpush"; +import { createUnifiedPushNotification, sendTestUnifiedPushNotification, sendUnifiedPushNotification } from "../unifiedpush/unifiedpush"; // The interval in minutes to check for new notifications const INTERVAL = 1; @@ -29,6 +30,7 @@ async function checkUnreadReplies( let { replies } = await client.getReplies({ limit: 50, unread_only: true, + sort: "New", }); // Filter out replies newer than the last timestamp @@ -75,6 +77,7 @@ async function checkUnreadMentions( let { mentions } = await client.getPersonMentions({ limit: 50, unread_only: true, + sort: "New", }); // Filter out mentions newer than the last timestamp @@ -147,6 +150,25 @@ async function checkUnreadMessages( } } +async function checkTests(notification: AccountNotification) { + if (notification.get("testQueued") as boolean) { + console.log('Found 1 test queued'); + + const notificationType = notification.get("type") as string; + const token = notification.get("token") as string; + + switch (notificationType) { + case "apn": + // TODO: Implement this for APN + case "unifiedPush": + await sendTestUnifiedPushNotification(token); + break; + default: + break; + } + } +} + /** * The main function that checks for new notifications. This is triggered from a cron job and is ran at every `INTERVAL` minutes. */ @@ -154,7 +176,7 @@ const checkNotifications = async () => { triggerDateTime = new Date(); const notifications = await AccountNotification.findAll({ - where: { timestamp: { [Op.lt]: subMinutes(triggerDateTime, INTERVAL) } }, + where: Sequelize.or({ timestamp: { [Op.lt]: subMinutes(triggerDateTime, INTERVAL) } }, { testQueued: true }), }); console.log("Found " + notifications.length + " notifications to check"); @@ -191,9 +213,10 @@ const checkNotifications = async () => { await checkUnreadReplies(client, notification); await checkUnreadMentions(client, notification); await checkUnreadMessages(client, notification); + await checkTests(notification); // Update the notification timestamp - await notification.update({ timestamp: triggerDateTime }); + await notification.update({ timestamp: triggerDateTime, testQueued: false }); } catch (error) { console.error(error); } @@ -221,4 +244,4 @@ const notificationService = cron.schedule( } ); -export default notificationService; +export { notificationService, checkNotifications }; diff --git a/src/notifications/unifiedpush/unifiedpush.ts b/src/notifications/unifiedpush/unifiedpush.ts index eac75fe..115107f 100644 --- a/src/notifications/unifiedpush/unifiedpush.ts +++ b/src/notifications/unifiedpush/unifiedpush.ts @@ -3,9 +3,14 @@ import { PersonMentionView, PrivateMessageView, } from "lemmy-js-client"; +import { setGlobalDispatcher, Agent } from "undici"; import { UnifiedPushObject } from "./../../types/unified_push_object"; +import { createSlimCommentReplyView } from "../../types/lemmy"; + +setGlobalDispatcher(new Agent({connect: { timeout: 30_000 }})); + /** * Creates the template for the notification to be sent to UnifiedPush. * @@ -21,7 +26,8 @@ function createUnifiedPushNotification( message: PrivateMessageView | undefined ): UnifiedPushObject { const note: UnifiedPushObject = { - reply: reply, + reply: createSlimCommentReplyView(reply), + // TODO: Use slim models for mention/message below mention: mention, message: message, }; @@ -48,4 +54,19 @@ async function sendUnifiedPushNotification( }); } -export { createUnifiedPushNotification, sendUnifiedPushNotification }; +/** + * Sends the notification through UnifiedPush. + */ +async function sendTestUnifiedPushNotification( + token: string +) { + fetch(token, { + method: "POST", + body: 'test', + headers: { + "Content-type": "application/json; charset=UTF-8", + }, + }); +} + +export { createUnifiedPushNotification, sendUnifiedPushNotification, sendTestUnifiedPushNotification }; diff --git a/src/types/lemmy.ts b/src/types/lemmy.ts new file mode 100644 index 0000000..fa21cec --- /dev/null +++ b/src/types/lemmy.ts @@ -0,0 +1,37 @@ +import { + CommentReplyView, +} from "lemmy-js-client"; + +export type SlimCommentReplyView = { + comment_reply_id: number, + comment_content: string, + comment_removed: boolean, + comment_deleted: boolean, + creator_name: string, + creator_actor_id: string, + post_name: string, + community_name: string, + community_actor_id: string, + recipient_name: string, + recipient_actor_id: string, +} + +export function createSlimCommentReplyView(reply: CommentReplyView | undefined): SlimCommentReplyView | undefined { + if (!reply) return undefined; + + return { + comment_reply_id: reply.comment_reply.id, + comment_content: reply.comment.content, + comment_removed: reply.comment.removed, + comment_deleted: reply.comment.deleted, + creator_name: reply.creator.name, + creator_actor_id: reply.creator.actor_id, + post_name: reply.post.name, + community_name: reply.community.name, + community_actor_id: reply.community.actor_id, + recipient_name: reply.recipient.name, + recipient_actor_id: reply.recipient.actor_id, + }; +} + +// TODO: Add slim models for mentions and messages diff --git a/src/types/unified_push_object.ts b/src/types/unified_push_object.ts index e98c810..2f4f2b7 100644 --- a/src/types/unified_push_object.ts +++ b/src/types/unified_push_object.ts @@ -1,11 +1,12 @@ import { - CommentReplyView, PersonMentionView, PrivateMessageView, } from "lemmy-js-client"; +import { SlimCommentReplyView } from "./lemmy"; + export type UnifiedPushObject = { mention: PersonMentionView | undefined, - reply: CommentReplyView | undefined, + reply: SlimCommentReplyView | undefined, message: PrivateMessageView | undefined };