@ -0,0 +1,44 @@
|
|||||||
|
name: Overseerr Preview
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'preview-*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_and_push:
|
||||||
|
name: Build & Publish Docker Preview Images
|
||||||
|
runs-on: ubuntu-20.04
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
- name: Get the version
|
||||||
|
id: get_version
|
||||||
|
run: echo ::set-output name=VERSION::${GITHUB_REF#refs/tags/}
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64
|
||||||
|
push: true
|
||||||
|
build-args: |
|
||||||
|
COMMIT_TAG=${{ github.sha }}
|
||||||
|
tags: |
|
||||||
|
sctx/overseerr:${{ steps.get_version.outputs.VERSION }}
|
||||||
|
ghcr.io/sct/overseerr:${{ steps.get_version.outputs.VERSION }}
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 26 KiB |
After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 83 KiB |
After Width: | Height: | Size: 70 KiB |
After Width: | Height: | Size: 7.8 KiB |
After Width: | Height: | Size: 6.0 KiB |
Before Width: | Height: | Size: 969 B |
Before Width: | Height: | Size: 2.3 KiB |
@ -0,0 +1,69 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
|
||||||
|
<title>You are offline</title>
|
||||||
|
|
||||||
|
<!-- Inline the page's stylesheet. -->
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: helvetica, arial, sans-serif;
|
||||||
|
margin: 2em;
|
||||||
|
background-color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #6366F1;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-block: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>You are offline</h1>
|
||||||
|
|
||||||
|
<button type="button">⤾ Reload</button>
|
||||||
|
|
||||||
|
<!-- Inline the page's JavaScript file. -->
|
||||||
|
<script>
|
||||||
|
// Manual reload feature.
|
||||||
|
document.querySelector("button").addEventListener("click", () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen to changes in the network state, reload when online.
|
||||||
|
// This handles the case when the device is completely offline.
|
||||||
|
window.addEventListener('online', () => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if the server is responding and reload the page if it is.
|
||||||
|
// This handles the case when the device is online, but the server
|
||||||
|
// is offline or misbehaving.
|
||||||
|
async function checkNetworkAndReload() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('.');
|
||||||
|
// Verify we get a valid response from the server
|
||||||
|
if (response.status >= 200 && response.status < 500) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Unable to connect to the server, ignore.
|
||||||
|
}
|
||||||
|
window.setTimeout(checkNetworkAndReload, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkNetworkAndReload();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
Before Width: | Height: | Size: 678 KiB After Width: | Height: | Size: 455 KiB |
After Width: | Height: | Size: 6.9 KiB |
@ -0,0 +1,136 @@
|
|||||||
|
// Incrementing OFFLINE_VERSION will kick off the install event and force
|
||||||
|
// previously cached resources to be updated from the network.
|
||||||
|
// This variable is intentionally declared and unused.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const OFFLINE_VERSION = 3;
|
||||||
|
const CACHE_NAME = "offline";
|
||||||
|
// Customize this with a different URL if needed.
|
||||||
|
const OFFLINE_URL = "/offline.html";
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
// Setting {cache: 'reload'} in the new request will ensure that the
|
||||||
|
// response isn't fulfilled from the HTTP cache; i.e., it will be from
|
||||||
|
// the network.
|
||||||
|
await cache.add(new Request(OFFLINE_URL, { cache: "reload" }));
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
// Force the waiting service worker to become the active service worker.
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
(async () => {
|
||||||
|
// Enable navigation preload if it's supported.
|
||||||
|
// See https://developers.google.com/web/updates/2017/02/navigation-preload
|
||||||
|
if ("navigationPreload" in self.registration) {
|
||||||
|
await self.registration.navigationPreload.enable();
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tell the active service worker to take control of the page immediately.
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
// We only want to call event.respondWith() if this is a navigation request
|
||||||
|
// for an HTML page.
|
||||||
|
if (event.request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// First, try to use the navigation preload response if it's supported.
|
||||||
|
const preloadResponse = await event.preloadResponse;
|
||||||
|
if (preloadResponse) {
|
||||||
|
return preloadResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always try the network first.
|
||||||
|
const networkResponse = await fetch(event.request);
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
// catch is only triggered if an exception is thrown, which is likely
|
||||||
|
// due to a network error.
|
||||||
|
// If fetch() returns a valid HTTP response with a response code in
|
||||||
|
// the 4xx or 5xx range, the catch() will NOT be called.
|
||||||
|
console.log("Fetch failed; returning offline page instead.", error);
|
||||||
|
|
||||||
|
const cache = await caches.open(CACHE_NAME);
|
||||||
|
const cachedResponse = await cache.match(OFFLINE_URL);
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('push', (event) => {
|
||||||
|
const payload = event.data ? event.data.json() : {};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
body: payload.message,
|
||||||
|
icon: payload.image ? payload.image : 'android-chrome-192x192.png',
|
||||||
|
vibrate: [100, 50, 100],
|
||||||
|
data: {
|
||||||
|
dateOfArrival: Date.now(),
|
||||||
|
primaryKey: '2',
|
||||||
|
actionUrl: payload.actionUrl,
|
||||||
|
requestId: payload.requestId,
|
||||||
|
},
|
||||||
|
actions: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.actionUrl){
|
||||||
|
options.actions.push(
|
||||||
|
{
|
||||||
|
action: 'viewmedia',
|
||||||
|
title: 'View Media',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.notificationType === 'MEDIA_PENDING') {
|
||||||
|
options.actions.push(
|
||||||
|
{
|
||||||
|
action: 'approve',
|
||||||
|
title: 'Approve',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'decline',
|
||||||
|
title: 'Decline',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
self.registration.showNotification(payload.subject, options)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
const notificationData = event.notification.data;
|
||||||
|
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
if (event.action === 'viewmedia') {
|
||||||
|
self.clients.openWindow(notificationData.actionUrl);
|
||||||
|
} else if (event.action === 'approve') {
|
||||||
|
fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
self.clients.openWindow(notificationData.actionUrl);
|
||||||
|
} else if (event.action === 'decline') {
|
||||||
|
fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
self.clients.openWindow(notificationData.actionUrl);
|
||||||
|
} else if (notificationData.actionUrl) {
|
||||||
|
self.clients.openWindow(notificationData.actionUrl);
|
||||||
|
}
|
||||||
|
}, false);
|
After Width: | Height: | Size: 3.9 KiB |
@ -0,0 +1,27 @@
|
|||||||
|
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||||
|
import { User } from './User';
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class UserPushSubscription {
|
||||||
|
@PrimaryGeneratedColumn()
|
||||||
|
public id: number;
|
||||||
|
|
||||||
|
@ManyToOne(() => User, (user) => user.pushSubscriptions, {
|
||||||
|
eager: true,
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
})
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
public endpoint: string;
|
||||||
|
|
||||||
|
@Column()
|
||||||
|
public p256dh: string;
|
||||||
|
|
||||||
|
@Column({ unique: true })
|
||||||
|
public auth: string;
|
||||||
|
|
||||||
|
constructor(init?: Partial<UserPushSubscription>) {
|
||||||
|
Object.assign(this, init);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,104 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { hasNotificationType, Notification } from '..';
|
||||||
|
import { MediaStatus } from '../../../constants/media';
|
||||||
|
import logger from '../../../logger';
|
||||||
|
import { getSettings, NotificationAgentLunaSea } from '../../settings';
|
||||||
|
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||||
|
|
||||||
|
class LunaSeaAgent
|
||||||
|
extends BaseAgent<NotificationAgentLunaSea>
|
||||||
|
implements NotificationAgent {
|
||||||
|
protected getSettings(): NotificationAgentLunaSea {
|
||||||
|
if (this.settings) {
|
||||||
|
return this.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
return settings.notifications.agents.lunasea;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPayload(type: Notification, payload: NotificationPayload) {
|
||||||
|
return {
|
||||||
|
notification_type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
message: payload.message,
|
||||||
|
image: payload.image ?? null,
|
||||||
|
email: payload.notifyUser?.email,
|
||||||
|
username: payload.notifyUser?.username,
|
||||||
|
avatar: payload.notifyUser?.avatar,
|
||||||
|
media: payload.media
|
||||||
|
? {
|
||||||
|
media_type: payload.media.mediaType,
|
||||||
|
tmdbId: payload.media.tmdbId,
|
||||||
|
imdbId: payload.media.imdbId,
|
||||||
|
tvdbId: payload.media.tvdbId,
|
||||||
|
status: MediaStatus[payload.media.status],
|
||||||
|
status4k: MediaStatus[payload.media.status4k],
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
extra: payload.extra ?? [],
|
||||||
|
request: payload.request
|
||||||
|
? {
|
||||||
|
request_id: payload.request.id,
|
||||||
|
requestedBy_email: payload.request.requestedBy.email,
|
||||||
|
requestedBy_username: payload.request.requestedBy.displayName,
|
||||||
|
requestedBy_avatar: payload.request.requestedBy.avatar,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public shouldSend(type: Notification): boolean {
|
||||||
|
if (
|
||||||
|
this.getSettings().enabled &&
|
||||||
|
this.getSettings().options.webhookUrl &&
|
||||||
|
hasNotificationType(type, this.getSettings().types)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async send(
|
||||||
|
type: Notification,
|
||||||
|
payload: NotificationPayload
|
||||||
|
): Promise<boolean> {
|
||||||
|
logger.debug('Sending LunaSea notification', {
|
||||||
|
label: 'Notifications',
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { webhookUrl, profileName } = this.getSettings().options;
|
||||||
|
|
||||||
|
if (!webhookUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.post(webhookUrl, this.buildPayload(type, payload), {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${Buffer.from(`${profileName}:`).toString(
|
||||||
|
'base64'
|
||||||
|
)}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error sending LunaSea notification', {
|
||||||
|
label: 'Notifications',
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
errorMessage: e.message,
|
||||||
|
response: e.response.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LunaSeaAgent;
|
@ -0,0 +1,234 @@
|
|||||||
|
import { getRepository } from 'typeorm';
|
||||||
|
import webpush from 'web-push';
|
||||||
|
import { hasNotificationType, Notification } from '..';
|
||||||
|
import { MediaType } from '../../../constants/media';
|
||||||
|
import { User } from '../../../entity/User';
|
||||||
|
import { UserPushSubscription } from '../../../entity/UserPushSubscription';
|
||||||
|
import logger from '../../../logger';
|
||||||
|
import { Permission } from '../../permissions';
|
||||||
|
import {
|
||||||
|
getSettings,
|
||||||
|
NotificationAgentConfig,
|
||||||
|
NotificationAgentKey,
|
||||||
|
} from '../../settings';
|
||||||
|
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
|
||||||
|
|
||||||
|
interface PushNotificationPayload {
|
||||||
|
notificationType: string;
|
||||||
|
mediaType?: 'movie' | 'tv';
|
||||||
|
tmdbId?: number;
|
||||||
|
subject: string;
|
||||||
|
message?: string;
|
||||||
|
image?: string;
|
||||||
|
actionUrl?: string;
|
||||||
|
requestId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class WebPushAgent
|
||||||
|
extends BaseAgent<NotificationAgentConfig>
|
||||||
|
implements NotificationAgent {
|
||||||
|
protected getSettings(): NotificationAgentConfig {
|
||||||
|
if (this.settings) {
|
||||||
|
return this.settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
return settings.notifications.agents.webpush;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getNotificationPayload(
|
||||||
|
type: Notification,
|
||||||
|
payload: NotificationPayload
|
||||||
|
): PushNotificationPayload {
|
||||||
|
switch (type) {
|
||||||
|
case Notification.TEST_NOTIFICATION:
|
||||||
|
return {
|
||||||
|
notificationType: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
message: payload.message,
|
||||||
|
};
|
||||||
|
case Notification.MEDIA_APPROVED:
|
||||||
|
return {
|
||||||
|
notificationType: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
message: `Your ${
|
||||||
|
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||||
|
} request has been approved.`,
|
||||||
|
image: payload.image,
|
||||||
|
mediaType: payload.media?.mediaType,
|
||||||
|
tmdbId: payload.media?.tmdbId,
|
||||||
|
requestId: payload.request?.id,
|
||||||
|
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||||
|
};
|
||||||
|
case Notification.MEDIA_AUTO_APPROVED:
|
||||||
|
return {
|
||||||
|
notificationType: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
message: `Automatically approved a new ${
|
||||||
|
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||||
|
} request from ${payload.request?.requestedBy.displayName}.`,
|
||||||
|
image: payload.image,
|
||||||
|
mediaType: payload.media?.mediaType,
|
||||||
|
tmdbId: payload.media?.tmdbId,
|
||||||
|
requestId: payload.request?.id,
|
||||||
|
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||||
|
};
|
||||||
|
case Notification.MEDIA_AVAILABLE:
|
||||||
|
return {
|
||||||
|
notificationType: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
message: `Your ${
|
||||||
|
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||||
|
} request is now available!`,
|
||||||
|
image: payload.image,
|
||||||
|
mediaType: payload.media?.mediaType,
|
||||||
|
tmdbId: payload.media?.tmdbId,
|
||||||
|
requestId: payload.request?.id,
|
||||||
|
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||||
|
};
|
||||||
|
case Notification.MEDIA_DECLINED:
|
||||||
|
return {
|
||||||
|
notificationType: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
message: `Your ${
|
||||||
|
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||||
|
} request was declined.`,
|
||||||
|
image: payload.image,
|
||||||
|
mediaType: payload.media?.mediaType,
|
||||||
|
tmdbId: payload.media?.tmdbId,
|
||||||
|
requestId: payload.request?.id,
|
||||||
|
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||||
|
};
|
||||||
|
case Notification.MEDIA_FAILED:
|
||||||
|
return {
|
||||||
|
notificationType: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
message: `Failed to process ${
|
||||||
|
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||||
|
} request.`,
|
||||||
|
image: payload.image,
|
||||||
|
mediaType: payload.media?.mediaType,
|
||||||
|
tmdbId: payload.media?.tmdbId,
|
||||||
|
requestId: payload.request?.id,
|
||||||
|
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||||
|
};
|
||||||
|
case Notification.MEDIA_PENDING:
|
||||||
|
return {
|
||||||
|
notificationType: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
message: `Approval required for new ${
|
||||||
|
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
|
||||||
|
} request from ${payload.request?.requestedBy.displayName}.`,
|
||||||
|
image: payload.image,
|
||||||
|
mediaType: payload.media?.mediaType,
|
||||||
|
tmdbId: payload.media?.tmdbId,
|
||||||
|
requestId: payload.request?.id,
|
||||||
|
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public shouldSend(type: Notification): boolean {
|
||||||
|
if (
|
||||||
|
this.getSettings().enabled &&
|
||||||
|
hasNotificationType(type, this.getSettings().types)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async send(
|
||||||
|
type: Notification,
|
||||||
|
payload: NotificationPayload
|
||||||
|
): Promise<boolean> {
|
||||||
|
logger.debug('Sending web push notification', {
|
||||||
|
label: 'Notifications',
|
||||||
|
type: Notification[type],
|
||||||
|
subject: payload.subject,
|
||||||
|
});
|
||||||
|
const userRepository = getRepository(User);
|
||||||
|
const userPushSubRepository = getRepository(UserPushSubscription);
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
let pushSubs: UserPushSubscription[] = [];
|
||||||
|
|
||||||
|
const mainUser = await userRepository.findOne({ where: { id: 1 } });
|
||||||
|
|
||||||
|
if (
|
||||||
|
payload.notifyUser &&
|
||||||
|
// Check if user has webpush notifications enabled and fallback to true if undefined
|
||||||
|
// since web push should default to true
|
||||||
|
(payload.notifyUser.settings?.hasNotificationType(
|
||||||
|
NotificationAgentKey.WEBPUSH,
|
||||||
|
type
|
||||||
|
) ??
|
||||||
|
true)
|
||||||
|
) {
|
||||||
|
const notifySubs = await userPushSubRepository.find({
|
||||||
|
where: { user: payload.notifyUser.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
pushSubs = notifySubs;
|
||||||
|
} else if (!payload.notifyUser) {
|
||||||
|
const users = await userRepository.find();
|
||||||
|
|
||||||
|
const manageUsers = users.filter(
|
||||||
|
(user) =>
|
||||||
|
user.hasPermission(Permission.MANAGE_REQUESTS) &&
|
||||||
|
// Check if user has webpush notifications enabled and fallback to true if undefined
|
||||||
|
// since web push should default to true
|
||||||
|
(user.settings?.hasNotificationType(
|
||||||
|
NotificationAgentKey.WEBPUSH,
|
||||||
|
type
|
||||||
|
) ??
|
||||||
|
true)
|
||||||
|
);
|
||||||
|
|
||||||
|
const allSubs = await userPushSubRepository
|
||||||
|
.createQueryBuilder('pushSub')
|
||||||
|
.where('pushSub.userId IN (:users)', {
|
||||||
|
users: manageUsers.map((user) => user.id),
|
||||||
|
})
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
pushSubs = allSubs;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mainUser && pushSubs.length > 0) {
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
`mailto:${mainUser.email}`,
|
||||||
|
settings.vapidPublic,
|
||||||
|
settings.vapidPrivate
|
||||||
|
);
|
||||||
|
|
||||||
|
Promise.all(
|
||||||
|
pushSubs.map(async (sub) => {
|
||||||
|
try {
|
||||||
|
await webpush.sendNotification(
|
||||||
|
{
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: {
|
||||||
|
auth: sub.auth,
|
||||||
|
p256dh: sub.p256dh,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Buffer.from(
|
||||||
|
JSON.stringify(this.getNotificationPayload(type, payload)),
|
||||||
|
'utf-8'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
// Failed to send notification so we need to remove the subscription
|
||||||
|
userPushSubRepository.remove(sub);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WebPushAgent;
|
@ -1,16 +0,0 @@
|
|||||||
export enum NotificationAgentType {
|
|
||||||
NONE = 0,
|
|
||||||
EMAIL = 2,
|
|
||||||
DISCORD = 4,
|
|
||||||
TELEGRAM = 8,
|
|
||||||
PUSHOVER = 16,
|
|
||||||
PUSHBULLET = 32,
|
|
||||||
SLACK = 64,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const hasNotificationAgentEnabled = (
|
|
||||||
agent: NotificationAgentType,
|
|
||||||
value: number
|
|
||||||
): boolean => {
|
|
||||||
return !!(value & agent);
|
|
||||||
};
|
|
@ -0,0 +1,36 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateUserPushSubscriptions1618912653565
|
||||||
|
implements MigrationInterface {
|
||||||
|
name = 'CreateUserPushSubscriptions1618912653565';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"), CONSTRAINT "FK_03f7958328e311761b0de675fbe" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_push_subscription" RENAME TO "user_push_subscription"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_push_subscription" RENAME TO "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_push_subscription" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "endpoint" varchar NOT NULL, "p256dh" varchar NOT NULL, "auth" varchar NOT NULL, "userId" integer, CONSTRAINT "UQ_f90ab5a4ed54905a4bb51a7148b" UNIQUE ("auth"))`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_push_subscription"("id", "endpoint", "p256dh", "auth", "userId") SELECT "id", "endpoint", "p256dh", "auth", "userId" FROM "temporary_user_push_subscription"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_push_subscription"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_push_subscription"`);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUserSettingsLocale1619239659754 implements MigrationInterface {
|
||||||
|
name = 'AddUserSettingsLocale1619239659754';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_settings"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,52 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddUserSettingsNotificationTypes1619339817343
|
||||||
|
implements MigrationInterface {
|
||||||
|
name = 'AddUserSettingsNotificationTypes1619339817343';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_settings"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" text, "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "temporary_user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "user_settings"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationTypes" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_settings"("id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, "locale" varchar NOT NULL DEFAULT (''), CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
|
||||||
|
);
|
||||||
|
await queryRunner.query(
|
||||||
|
`INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale") SELECT "id", "notificationTypes", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey", "locale" FROM "temporary_user_settings"`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
|
||||||
|
}
|
||||||
|
}
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 519 B |
Before Width: | Height: | Size: 639 B After Width: | Height: | Size: 563 B |
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.3 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1014 B |
Before Width: | Height: | Size: 676 B After Width: | Height: | Size: 603 B |
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 737 B |
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
After Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 399 B After Width: | Height: | Size: 373 B |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
@ -0,0 +1,54 @@
|
|||||||
|
import { EyeIcon, EyeOffIcon } from '@heroicons/react/solid';
|
||||||
|
import { Field } from 'formik';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface CustomInputProps extends React.ComponentProps<'input'> {
|
||||||
|
as?: 'input';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CustomFieldProps extends React.ComponentProps<typeof Field> {
|
||||||
|
as?: 'field';
|
||||||
|
}
|
||||||
|
|
||||||
|
type SensitiveInputProps = CustomInputProps | CustomFieldProps;
|
||||||
|
|
||||||
|
const SensitiveInput: React.FC<SensitiveInputProps> = ({
|
||||||
|
as = 'input',
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [isHidden, setHidden] = useState(true);
|
||||||
|
const Component = as === 'input' ? 'input' : Field;
|
||||||
|
const componentProps =
|
||||||
|
as === 'input'
|
||||||
|
? props
|
||||||
|
: {
|
||||||
|
...props,
|
||||||
|
as: props.type === 'textarea' && !isHidden ? 'textarea' : undefined,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Component
|
||||||
|
{...componentProps}
|
||||||
|
className={`rounded-l-only ${componentProps.className ?? ''}`}
|
||||||
|
type={
|
||||||
|
isHidden
|
||||||
|
? 'password'
|
||||||
|
: props.type !== 'password'
|
||||||
|
? props.type ?? 'text'
|
||||||
|
: 'text'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setHidden(!isHidden);
|
||||||
|
}}
|
||||||
|
className="input-action"
|
||||||
|
>
|
||||||
|
{isHidden ? <EyeOffIcon /> : <EyeIcon />}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SensitiveInput;
|