Service worker push working

This commit is contained in:
Fergal Moran
2018-03-27 17:15:07 +01:00
parent 30b0959416
commit b826b3b20a
36 changed files with 1420 additions and 354 deletions

View File

@@ -7,7 +7,12 @@
{
"root": "src",
"outDir": "dist",
"assets": ["assets", "favicon.ico"],
"assets": [
"assets",
"favicon.ico",
"firebase-messaging-sw.js",
"manifest.json"
],
"index": "index.html",
"main": "main.ts",
"polyfills": "polyfills.ts",
@@ -15,7 +20,6 @@
"tsconfig": "tsconfig.app.json",
"testTsconfig": "tsconfig.spec.json",
"prefix": "app",
"serviceWorker": true,
"styles": [
"../node_modules/font-awesome/css/font-awesome.css",
"../node_modules/simple-line-icons/css/simple-line-icons.css",
@@ -58,6 +62,5 @@
"defaults": {
"styleExt": "css",
"component": {}
}
}

1042
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,7 +24,6 @@
"@angular/platform-browser": "^5.2.7",
"@angular/platform-browser-dynamic": "^5.2.7",
"@angular/router": "^5.2.7",
"@angular/service-worker": "^5.2.9",
"@aspnet/signalr": "^1.0.0-preview3-30392",
"@ngrx/effects": "^5.1.0",
"@ngrx/store": "^5.1.0",
@@ -32,12 +31,14 @@
"@qontu/ngx-inline-editor": "^0.2.0-alpha.12",
"angular2-jwt": "^0.2.3",
"angular2-moment": "^1.8.0",
"angularfire2": "^5.0.0-rc.6",
"applicationinsights-js": "^1.0.15",
"auth0": "^2.9.1",
"auth0-lock": "^11.4.0",
"bootstrap": "4.0.0",
"core-js": "^2.5.3",
"dropzone": "^5.3.0",
"firebase": "^4.12.0",
"font-awesome": "^4.7.0",
"jquery": "^3.3.1",
"lodash": "^4.17.5",

View File

@@ -1,11 +1,12 @@
import { GlobalsService } from './services/globals.service';
import { Component, HostBinding, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { ToastyService } from 'ng2-toasty';
import { AuthService } from 'app/services/auth.service';
import { AppInsightsService } from 'app/services/app-insights.service';
import { SignalRService } from 'app/services/signalr.service';
import { ProfileService } from './services/profile.service';
import { PushNotificationsService } from 'app/services/push-notifications.service';
import { MessagingService } from 'app/services/messaging.service';
@Component({
selector: 'app-root',
@@ -15,9 +16,10 @@ import { PushNotificationsService } from 'app/services/push-notifications.servic
export class AppComponent implements OnInit {
constructor(
private _authService: AuthService,
private _toastyService: ToastyService,
private _signalrService: SignalRService,
private _profileService: ProfileService,
private _pushNotifications: PushNotificationsService,
private _messagingService: MessagingService,
_appInsights: AppInsightsService
) {
_authService.handleAuthentication();
@@ -33,6 +35,8 @@ export class AppComponent implements OnInit {
if (this.loggedIn()) {
const user = this._profileService.getProfile().subscribe(u => {
if (u) {
this._messagingService.getPermission();
this._messagingService.receiveMessage();
const chatterChannel = `${u.uid}_chatter`;
this._signalrService
.init('chatter')
@@ -45,10 +49,7 @@ export class AppComponent implements OnInit {
this._signalrService.connection.on(
chatterChannel,
result => {
this._pushNotifications.createNotification(
'PodNoms',
result
);
this._toastyService.info(result);
}
);
})

View File

@@ -9,6 +9,9 @@ import { HttpModule, Http, RequestOptions } from '@angular/http';
import { FormsModule } from '@angular/forms';
import { ToastyModule } from 'ng2-toasty';
import { ProgressbarModule } from 'ngx-bootstrap/progressbar';
import { AngularFireDatabaseModule } from 'angularfire2/database';
import { AngularFireAuthModule } from 'angularfire2/auth';
import { AngularFireModule } from 'angularfire2';
import { ModalModule } from 'ngx-bootstrap/modal';
import { AuthGuard } from './services/auth.guard';
@@ -23,7 +26,6 @@ import { StoreModule } from '@ngrx/store';
import { EffectsModule } from '@ngrx/effects';
import { StoreDevtoolsModule } from '@ngrx/store-devtools';
import { ClipboardModule } from 'ngx-clipboard';
import { ServiceWorkerModule } from '@angular/service-worker';
import { AppComponent } from './app.component';
import { HomeComponent } from './components/home/home.component';
@@ -51,9 +53,9 @@ import { ProfileComponent } from './components/profile/profile.component';
import { AboutComponent } from './components/about/about.component';
import { FooterComponent } from './components/footer/footer.component';
import { JobsService } from 'app/services/jobs.service';
import { PushService } from 'app/services/push.service';
import { PushRegistrationService } from 'app/services/push-registration.service';
import { AppInsightsService } from 'app/services/app-insights.service';
import { PushNotificationsService } from './services/push-notifications.service';
import { MessagingService } from './services/messaging.service';
import { environment } from 'environments/environment';
@@ -95,7 +97,17 @@ export function authHttpServiceFactory(http: Http, options: RequestOptions) {
],
imports: [
BrowserModule,
ServiceWorkerModule.register('/ngsw-worker.js', {enabled: environment.production}),
AngularFireModule.initializeApp({
apiKey: 'AIzaSyAaIm8LTB0ZgJ-g7RXEjtVa1EOQB381QLI',
authDomain: 'podnoms-797e3.firebaseapp.com',
databaseURL: 'https://podnoms-797e3.firebaseio.com',
projectId: 'podnoms-797e3',
storageBucket: 'podnoms-797e3.appspot.com',
messagingSenderId: '777042345082'
}),
AngularFireDatabaseModule,
AngularFireAuthModule,
AppRoutingModule,
HttpModule,
FormsModule,
@@ -130,9 +142,9 @@ export function authHttpServiceFactory(http: Http, options: RequestOptions) {
ProfileService,
PodcastService,
ImageService,
PushService,
PushRegistrationService,
DebugService,
PushNotificationsService,
MessagingService,
ChatterService,
AppInsightsService,
JobsService,

View File

@@ -7,35 +7,31 @@
Desktop Notifications
</div>
<div class="block-content">
<input type="text" id="text" [(ngModel)]="notificationMessage">
<button class="btn btn-primary" (click)="sendDesktopNotification()">Send Desktop Notification</button>
<button class="btn btn-danger" (click)="subscribeToServerPush()">Subscribe to server push</button>
<button class="btn btn-danger" (click)="sendServerPush()">Send server push</button>
<br />
<div id="realtime-results" *ngIf="message$ | async as message">
<h5>{{ message.notification.title}}</h5>
<img [src]="message.notification.icon" width="100px;">
<p>{{ message.notification.body}}</p>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="row">
<div class="block">
<div class="block-header">
Realtime
</div>
<div class="block-header">Debug Info</div>
<div class="block-content">
<input type="text" id="text" [(ngModel)]="realtimeMessage">
<button class="btn btn-primary" (click)="sendMessage()">Send Realtime</button>
<ul class="list-unstyled">
<li *ngFor="let message of messagesReceived">
{{message}}
</li>
</ul>
</div>
</div>
</div>
<div class="row">
<div class="block">
<div class="block-header">
Chatter
</div>
<div class="block-content">
<button class="btn btn-primary" (click)="sendChatter()">Send Chatter</button>
<div class="row">
<pre [innerHtml]="(debugInfo$ | async) | prettyprint">
</pre>
</div>
<div class="row">
API Host: {{apiHost}}
<br /> SignalR Host: {{signalrHost}}
<br /> Ping: {{pingPong}} </div>
</div>
</div>
</div>
@@ -52,20 +48,5 @@
</div>
</div>
</div>
<div class="col-md-6">
<div class="block">
<div class="block-header">Debug Info</div>
<div class="block-content">
<div class="row">
<pre [innerHtml]="(debugInfo$ | async) | prettyprint">
</pre>
</div>
<div class="row">
API Host: {{apiHost}}
<br /> SignalR Host: {{signalrHost}}
<br /> Ping: {{pingPong}} </div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -5,7 +5,8 @@ import { DebugService } from 'app/services/debug.service';
import { environment } from 'environments/environment';
import { JobsService } from 'app/services/jobs.service';
import { ChatterService } from 'app/services/chatter.service';
import { PushNotificationsService } from 'app/services/push-notifications.service';
import { MessagingService } from 'app/services/messaging.service';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
@Component({
selector: 'app-debug',
@@ -13,10 +14,7 @@ import { PushNotificationsService } from 'app/services/push-notifications.servic
styleUrls: ['./debug.component.css']
})
export class DebugComponent implements OnInit {
realtimeMessage: string;
notificationMessage: string;
messagesReceived: string[] = [];
message$: BehaviorSubject<any>;
debugInfo$: Observable<string>;
apiHost = environment.API_HOST;
@@ -27,52 +25,24 @@ export class DebugComponent implements OnInit {
private _debugService: DebugService,
private _chatterService: ChatterService,
private _jobsService: JobsService,
private _pushNotifications: PushNotificationsService,
private _pushNotifications: MessagingService,
private _signalrService: SignalRService
) {}
ngOnInit() {
// this._signalrService
// .init(`${environment.SIGNALR_HOST}hubs/debug`)
// .then(() => {
// this._signalrService.connection.on('Send', data => {
// console.log('DebugService', 'signalr', data);
// this.messagesReceived.push(data);
// this.realtimeMessage = '';
// });
// this.debugInfo$ = this._debugService.getDebugInfo();
// })
// .catch(err =>
// console.error('debug.component.ts', '_signalrService.init', err)
// );
this._debugService.ping().subscribe(r => (this.pingPong = r));
}
sendMessage() {
subscribeToServerPush() {
this._pushNotifications.getPermission();
this._pushNotifications.receiveMessage();
this.message$ = this._pushNotifications.currentMessage;
}
sendServerPush() {
this._debugService
.sendRealtime(this.realtimeMessage)
.subscribe(r => console.log(r));
}
doSomething() {
alert('doSomething was did');
}
sendChatter() {
this._chatterService.ping('Pong').subscribe(r => {
this._pushNotifications.createNotification('PodNoms', r);
});
}
sendDesktopNotification() {
console.log(
'debug.component',
'sendDesktopFunction',
this.notificationMessage
);
this._pushNotifications.createNotification(
'PodNoms',
this.notificationMessage
);
}
subscribeToServerPush(){
this._pushNotifications.subscribeToServerPush();
.sendPush()
.subscribe(r =>
console.log('debug.component', 'sendServerPush', r)
);
}
processOrphans() {
this._jobsService

View File

@@ -3,6 +3,8 @@ import { ActivatedRoute } from '@angular/router';
import { Component } from '@angular/core';
import { PodcastModel, PodcastEntryModel } from 'app/models/podcasts.models';
import { ToastyService } from 'ng2-toasty';
import { PodcastService } from 'app/services/podcast.service';
import { MessagingService } from 'app/services/messaging.service';
import { AppComponent } from 'app/app.component';
import { Store } from '@ngrx/store';
import { ApplicationState } from 'app/store';
@@ -13,8 +15,6 @@ import { UpdateAction, AddAction } from 'app/actions/entries.actions';
import * as fromPodcast from 'app/reducers';
import * as fromPodcastActions from 'app/actions/podcast.actions';
import * as fromEntriesActions from 'app/actions/entries.actions';
import { PodcastService } from 'app/services/podcast.service';
import { PushNotificationsService } from 'app/services/push-notifications.service';
@Component({
selector: 'app-podcast',
@@ -102,12 +102,13 @@ export class PodcastComponent {
}
processPlaylist() {
if (this.pendingEntry) {
this._service.addPlaylist(this.pendingEntry)
.subscribe(e => {
if (e) {
this._toasty.info('Playlist added, check back here (and on your device) for new episodes');
}
});
this._service.addPlaylist(this.pendingEntry).subscribe(e => {
if (e) {
this._toasty.info(
'Playlist added, check back here (and on your device) for new episodes'
);
}
});
}
}
dismissPlaylist() {

View File

@@ -16,6 +16,9 @@ export class DebugService {
}
ping(): Observable<string> {
return this._http.get(environment.API_HOST + '/debug/ping').map(r => r.text());
return this._http.get(environment.API_HOST + '/ping').map(r => r.text());
}
sendPush(): Observable<string>{
return this._http.get(environment.API_HOST + '/debug/serverpush').map(r => r.text());
}
}

View File

@@ -0,0 +1,65 @@
import { Injectable } from '@angular/core';
import { AngularFireDatabase } from 'angularfire2/database';
import { AngularFireAuth } from 'angularfire2/auth';
import * as firebase from 'firebase';
import 'rxjs/add/operator/take';
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
import { PushRegistrationService } from 'app/services/push-registration.service';
@Injectable()
export class MessagingService {
messaging = firebase.messaging();
currentMessage = new BehaviorSubject(null);
constructor(
private db: AngularFireDatabase,
private afAuth: AngularFireAuth,
private _pushRegistrationServer: PushRegistrationService
) {
this.messaging.usePublicVapidKey(
'BKyhUqIVZLauKNA-DXPXbIVLj5XiWurHbRV_0Rd3BOjY5cU9GOrd5ptXVJ2CNExxdveKYzZevrep2CflKeqkyqo'
);
}
private updateToken(token) {
this.afAuth.authState.take(1).subscribe(user => {
if (!user) return;
const data = { [user.uid]: token };
this.db.object('fcmTokens/').update(data);
});
const registration = {
endpoint: token,
keys: {
p256dh: token
}
};
this._pushRegistrationServer.addSubscriber(registration)
.subscribe(e => console.log('messaging.service', 'updateToken', 'addSubscriber', e));
}
public getPermission() {
this.messaging
.requestPermission()
.then(() => {
console.log('Notification permission granted.');
const token = this.messaging.getToken();
return token;
})
.then(token => {
console.log(token);
this.updateToken(token);
})
.catch(err => {
console.error('Unable to get permission to notify', err);
});
}
receiveMessage() {
this.messaging.onMessage(payload => {
console.log('Message received', payload);
this.currentMessage.next(payload);
});
}
}

View File

@@ -1,79 +0,0 @@
import { Injectable } from '@angular/core';
import { SwPush } from '@angular/service-worker';
import { PushService } from 'app/services/push.service';
export type Permission = 'denied' | 'granted' | 'default';
@Injectable()
export class PushNotificationsService {
permission: Permission;
pushSupported: boolean = 'serviceWorker' in navigator && 'PushManager' in window;
vapidPublicKey: string = 'BKrxiuL9AJo5rrKEzMQIOvIacDnbg6JI8hJiT00JfKytur395xL8CROvR_zC2XM9f5oxGiMLxpUyjgLWlEPeSbU';
constructor(private _pushService: PushService, private _pushServiceWorker: SwPush) {}
private _urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/\-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
requestPermissions() {
if ('Notification' in window) {
Notification.requestPermission((status: any) => {
console.log(
'push-notifications.service',
'requestPermissions',
status
);
this.permission = status;
});
}
}
isSupported() {
return 'Notification' in window && this.permission == 'granted';
}
subscribeToServerPush() {
console.log('PushNotificationsService', 'subscribeToServerPush', this.vapidPublicKey);
this._pushServiceWorker.requestSubscription({
serverPublicKey: this.vapidPublicKey
}).then(pushSubscription => {
console.log('PushNotificationsService', 'subscribeToServerPush', pushSubscription);
this._pushService.addSubscriber(pushSubscription)
.subscribe(res => {
console.log('PushNotificationsService', 'subscribeToServerPush', res);
}, err => {
console.error('PushNotificationsService', 'subscribeToServerPush', err);
});
}).catch(err => {
console.error('PushNotificationsService', 'subscribeToServerPush', err);
});
}
createNotification(subject: string, text: string) {
console.log('PushNotificationsService', 'createNotification', subject, text);
if (this.isSupported()) {
const options = {
body: text,
icon: 'https://podnoms.com/assets/img/logo-icon.png'
};
const n = new Notification(subject, options);
} else {
console.error(
'push-notifications.service',
'createNotification',
'Notifications are not supported'
);
}
}
}

View File

@@ -9,7 +9,7 @@ import 'rxjs/add/observable/throw';
import { environment } from 'environments/environment';
@Injectable()
export class PushService {
export class PushRegistrationService {
private API_URL: string;
constructor(private http: AuthHttp) {
this.API_URL = environment.API_HOST;
@@ -27,14 +27,11 @@ export class PushService {
}
addSubscriber(subscription) {
const url = `${this.API_URL}/webpush/subscribe`;
console.log('[Push Service] Adding subscriber')
const url = `${this.API_URL}/webpush/subscribe`;
return this.http
.post(url, subscription)
.catch(this.handleError);
}
deleteSubscriber(subscription) {

View File

@@ -1,10 +1,13 @@
export const environment = {
production: false,
API_HOST: 'https://dev.podnoms.com:5001',
SIGNALR_HOST: 'https://dev.podnoms.com:5001/',
AUTH0_REDIRECT_URL: 'http://dev.podnoms.com:4200/callback',
BASE_URL: 'http://dev.podnoms.com:4200/',
API_HOST: 'http://localhost:5000',
SIGNALR_HOST: 'http://localhost:5000/',
AUTH0_REDIRECT_URL: 'http://localhost:4200/callback',
BASE_URL: 'http://localhost:4200/',
appInsights: {
instrumentationKey: '020b002a-bd3d-4b25-8a74-cab16fd39dfc'
},
messaging: {
endpoint: 'https://fcm.googleapis.com/fcm/send'
}
};

View File

@@ -0,0 +1,8 @@
importScripts('https://www.gstatic.com/firebasejs/3.9.0/firebase-app.js');
importScripts('https://www.gstatic.com/firebasejs/3.9.0/firebase-messaging.js');
firebase.initializeApp({
messagingSenderId: '777042345082'
});
const messaging = firebase.messaging();

View File

@@ -5,10 +5,10 @@
<!-- Google Analytics -->
<script>
(function (i, s, o, g, r, a, m) {
i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () {
(i[r].q = i[r].q || []).push(arguments)
}, i[r].l = 1 * new Date(); a = s.createElement(o),
m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m)
i['GoogleAnalyticsObject'] = r; i[r] = i[r] || function () {
(i[r].q = i[r].q || []).push(arguments)
}, i[r].l = 1 * new Date(); a = s.createElement(o),
m = s.getElementsByTagName(o)[0]; a.async = 1; a.src = g; m.parentNode.insertBefore(a, m)
})(window, document, 'script', 'https://www.google-analytics.com/analytics.js', 'ga');
ga('create', 'UA-115328404-1', 'auto');
@@ -18,22 +18,14 @@
<meta charset="utf-8">
<title>PodNoms.Web</title>
<base href="/">
<meta name="viewport"
content="width=device-width, initial-scale=1">
<link rel="icon"
type="image/x-icon"
compiler-c
href="favicon.ico">
<meta property="og:url"
content="https://podnoms.com" />
<meta property="og:type"
content="website" />
<meta property="og:title"
content="PodNoms" />
<meta property="og:description"
content="Robot Powered Podcasts" />
<meta property="og:image"
content="https://podnomscdn.blob.core.windows.net/static/images/robothand.jpg" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/x-icon" compiler-c href="favicon.ico">
<meta property="og:url" content="https://podnoms.com" />
<meta property="og:type" content="website" />
<meta property="og:title" content="PodNoms" />
<meta property="og:description" content="Robot Powered Podcasts" />
<meta property="og:image" content="https://podnomscdn.blob.core.windows.net/static/images/robothand.jpg" />
</head>
<body>

6
client/src/manifest.json Normal file
View File

@@ -0,0 +1,6 @@
{
"short_name": "FirebaseMessenger",
"name": "Angular4 + Firebase Starter App",
"start_url": "/?utm_source=homescreen",
"gcm_sender_id": "103953800507"
}

View File

@@ -1,25 +0,0 @@
{
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": ["/favicon.ico", "/index.html"],
"versionedFiles": [
"/*.bundle.css",
"/*.bundle.js",
"/*.chunk.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": ["/assets/**"]
}
}
]
}

View File

@@ -0,0 +1,31 @@
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PodNoms.Api.Models;
using PodNoms.Api.Persistence;
namespace PodNoms.Api.Controllers {
[Authorize]
public class AuthController : Controller {
protected IUserRepository _userRepository { get; }
public AuthController(IUserRepository repository) {
this._userRepository = repository;
}
protected async Task<User> GetUserAsync() {
var identifier = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
var user = await this._userRepository.GetAsync(identifier);
return user;
}
protected async Task<string> GetUserUidAsync() {
var user = await GetUserAsync();
return user.Uid;
}
protected async Task<int> GetUserIdAsync() {
var user = await GetUserAsync();
return user.Id;
}
}
}

View File

@@ -12,17 +12,20 @@ using PodNoms.Api.Persistence;
using PodNoms.Api.Services.Downloader;
using PodNoms.Api.Services.Hubs;
using PodNoms.Api.Services.Jobs;
using PodNoms.Api.Services.Push;
using PodNoms.Api.Services.Realtime;
using WebPush = Lib.Net.Http.WebPush;
namespace PodNoms.Api.Controllers {
[Route("[controller]")]
public class DebugController : Controller {
public class DebugController : AuthController {
private readonly StorageSettings _storageSettings;
private readonly AudioFileStorageSettings _audioFileStorageSettings;
private readonly ApplicationsSettings _applicationsSettings;
private readonly ImageFileStorageSettings _imageFileStorageSettings;
private readonly HubLifetimeManager<DebugHub> _hubManager;
private readonly IUserRepository _userRepository;
private readonly IPushSubscriptionStore _subscriptionStore;
private readonly IPushNotificationService _notificationService;
public AppSettings _appSettings { get; }
@@ -30,14 +33,17 @@ namespace PodNoms.Api.Controllers {
HubLifetimeManager<DebugHub> hubManager, IUserRepository userRepository,
IOptions<ApplicationsSettings> applicationsSettings,
IOptions<AudioFileStorageSettings> audioFileStorageSettings,
IOptions<ImageFileStorageSettings> imageFileStorageSettings) {
IOptions<ImageFileStorageSettings> imageFileStorageSettings,
IPushSubscriptionStore subscriptionStore,
IPushNotificationService notificationService) : base(userRepository) {
this._appSettings = appSettings.Value;
this._storageSettings = settings.Value;
this._applicationsSettings = applicationsSettings.Value;
this._audioFileStorageSettings = audioFileStorageSettings.Value;
this._imageFileStorageSettings = imageFileStorageSettings.Value;
this._hubManager = hubManager;
this._userRepository = userRepository;
this._subscriptionStore = subscriptionStore;
this._notificationService = notificationService;
}
[Authorize]
@@ -55,16 +61,6 @@ namespace PodNoms.Api.Controllers {
return new OkObjectResult(config);
}
[HttpGet("ping")]
public string Ping() {
return "Pong";
}
[HttpGet("clear")]
public IActionResult Clear() {
return Ok();
}
[Authorize]
[HttpPost("realtime")]
public async Task<IActionResult> Realtime([FromBody] string message) {
@@ -72,5 +68,20 @@ namespace PodNoms.Api.Controllers {
await _hubManager.SendAllAsync("Send", new string[] { $"All: {message}" });
return Ok(message);
}
[Authorize]
[HttpGet("serverpush")]
public async Task<string> ServerPush() {
WebPush.PushMessage pushMessage = new WebPush.PushMessage("Argle Bargle, Foo Ferra") {
Topic = "Debug",
Urgency = WebPush.PushMessageUrgency.Normal
};
var uid = await GetUserUidAsync();
await _subscriptionStore.ForEachSubscriptionAsync(uid, (subscription) => {
_notificationService.SendNotificationAsync(subscription, pushMessage);
});
return "Hello Sailor!";
}
}
}
}

View File

@@ -12,13 +12,14 @@ using PodNoms.Api.Models;
using PodNoms.Api.Models.ViewModels;
using PodNoms.Api.Persistence;
using PodNoms.Api.Services;
using PodNoms.Api.Services.Jobs;
using PodNoms.Api.Services.Processor;
using PodNoms.Api.Services.Storage;
namespace PodNoms.Api.Controllers {
[Route("[controller]")]
public class EntryController : Controller {
public class EntryController : AuthController {
private readonly IPodcastRepository _podcastRepository;
private readonly IEntryRepository _repository;
private readonly IUnitOfWork _unitOfWork;
@@ -29,10 +30,11 @@ namespace PodNoms.Api.Controllers {
private readonly StorageSettings _storageSettings;
public EntryController(IEntryRepository repository,
IUserRepository userRepository,
IPodcastRepository podcastRepository,
IUnitOfWork unitOfWork, IMapper mapper, IOptions<StorageSettings> storageSettings,
IOptions<AudioFileStorageSettings> audioFileStorageSettings,
IUrlProcessService processor, ILoggerFactory logger) {
IUrlProcessService processor, ILoggerFactory logger) : base(userRepository) {
this._logger = logger.CreateLogger<EntryController>();
this._podcastRepository = podcastRepository;
this._repository = repository;
@@ -47,8 +49,11 @@ namespace PodNoms.Api.Controllers {
try {
var extractJobId = BackgroundJob.Enqueue<IUrlProcessService>(
service => service.DownloadAudio(entry.Id));
var upload = BackgroundJob.ContinueWith<IAudioUploadProcessService>(
var uploadJobId = BackgroundJob.ContinueWith<IAudioUploadProcessService>(
extractJobId, service => service.UploadAudio(entry.Id, entry.AudioUrl));
var notify = BackgroundJob.ContinueWith<INotifyJobCompleteService>(
uploadJobId, service => service.NotifyUser(entry.Podcast.User.Uid, "PodNoms", $"{entry.Title} has finished processing",
entry.Podcast.ImageUrl));
} catch (InvalidOperationException ex) {
_logger.LogError($"Failed submitting job to processor\n{ex.Message}");
entry.ProcessingStatus = ProcessingStatus.Failed;

View File

@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Mvc;
namespace server.Controllers {
public class PingController : Controller {
[HttpGet]
public string Get() {
return "Pong";
}
}
}

View File

@@ -16,12 +16,10 @@ using PodNoms.Api.Services.Auth;
using PodNoms.Api.Services.Processor;
using PodNoms.Api.Utils.Extensions;
#endregion
namespace PodNoms.Api.Controllers
{
namespace PodNoms.Api.Controllers {
[Authorize]
[Route("[controller]")]
public class PodcastController : Controller
{
public class PodcastController : Controller {
private readonly IPodcastRepository _repository;
private readonly IUserRepository _userRepository;
private readonly IOptions<AppSettings> _settings;
@@ -29,8 +27,7 @@ namespace PodNoms.Api.Controllers
private readonly IUnitOfWork _uow;
public PodcastController(IPodcastRepository repository, IUserRepository userRepository,
IOptions<AppSettings> options, IMapper mapper, IUnitOfWork unitOfWork)
{
IOptions<AppSettings> options, IMapper mapper, IUnitOfWork unitOfWork) {
this._uow = unitOfWork;
this._repository = repository;
this._userRepository = userRepository;
@@ -39,11 +36,9 @@ namespace PodNoms.Api.Controllers
}
[HttpGet]
public async Task<IEnumerable<PodcastViewModel>> Get()
{
public async Task<IEnumerable<PodcastViewModel>> Get() {
var email = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
if (!string.IsNullOrEmpty(email))
{
if (!string.IsNullOrEmpty(email)) {
var podcasts = await _repository.GetAllAsync(email);
var ret = _mapper.Map<List<Podcast>, List<PodcastViewModel>>(podcasts.ToList());
return ret;
@@ -52,11 +47,9 @@ namespace PodNoms.Api.Controllers
}
[HttpGet("{slug}")]
public async Task<IActionResult> GetBySlug(string slug)
{
public async Task<IActionResult> GetBySlug(string slug) {
var email = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
if (!string.IsNullOrEmpty(email))
{
if (!string.IsNullOrEmpty(email)) {
var podcast = await _repository.GetAsync(email, slug);
if (podcast == null)
return NotFound();
@@ -66,15 +59,13 @@ namespace PodNoms.Api.Controllers
}
[HttpPost]
public async Task<IActionResult> Post([FromBody] PodcastViewModel vm)
{
public async Task<IActionResult> Post([FromBody] PodcastViewModel vm) {
var email = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
var user = _userRepository.Get(email);
if (string.IsNullOrEmpty(email) || user == null)
return new BadRequestObjectResult("Unable to look up user profile");
if (ModelState.IsValid)
{
if (ModelState.IsValid) {
var item = _mapper.Map<PodcastViewModel, Podcast>(vm);
item.User = user;
@@ -86,18 +77,15 @@ namespace PodNoms.Api.Controllers
}
[HttpPut]
public async Task<IActionResult> Put([FromBody] PodcastViewModel vm)
{
public async Task<IActionResult> Put([FromBody] PodcastViewModel vm) {
var email = User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.Email)?.Value;
var user = _userRepository.Get(email);
if (string.IsNullOrEmpty(email) || user == null)
return new BadRequestObjectResult("Unable to look up user profile");
if (ModelState.IsValid)
{
if (ModelState.IsValid) {
var podcast = await _repository.GetAsync(vm.Id);
if (podcast != null)
{
if (podcast != null) {
var item = _mapper.Map<PodcastViewModel, Podcast>(vm, podcast);
await _uow.CompleteAsync();
@@ -108,8 +96,7 @@ namespace PodNoms.Api.Controllers
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
public async Task<IActionResult> Delete(int id) {
await this._repository.DeleteAsync(id);
await _uow.CompleteAsync();
return Ok();

View File

@@ -1,25 +1,28 @@
using System.Threading.Tasks;
using Lib.Net.Http.WebPush;
using WebPush = Lib.Net.Http.WebPush;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PodNoms.Api.Services.Push;
using PodNoms.Api.Services.Push.Models;
using PodNoms.Api.Persistence;
namespace PodNoms.Api.Controllers {
// [Authorize]
[Route("[controller]")]
public class WebPushController : Controller {
public class WebPushController : AuthController {
private readonly IPushSubscriptionStore _subscriptionStore;
public readonly IPushNotificationService _notificationService;
public WebPushController(IPushSubscriptionStore subscriptionStore, IPushNotificationService notificationService) {
public WebPushController(IUserRepository userRepository, IPushSubscriptionStore subscriptionStore,
IPushNotificationService notificationService) : base(userRepository) {
this._subscriptionStore = subscriptionStore;
this._notificationService = notificationService;
}
[HttpPost("subscribe")]
public async Task<IActionResult> StoreSubscription([FromBody]PushSubscription subscription) {
public async Task<IActionResult> StoreSubscription([FromBody]WebPush.PushSubscription subscription) {
subscription.Keys["auth"] = $"{await this.GetUserUidAsync()}";
await _subscriptionStore.StoreSubscriptionAsync(subscription);
return NoContent();
}
@@ -27,13 +30,13 @@ namespace PodNoms.Api.Controllers {
// POST push-notifications-api/notifications
[HttpPost("message")]
public async Task<IActionResult> SendNotification([FromBody]PushMessageViewModel message) {
PushMessage pushMessage = new PushMessage(message.Notification) {
WebPush.PushMessage pushMessage = new WebPush.PushMessage(message.Notification) {
Topic = message.Topic,
Urgency = message.Urgency
};
// TODO: This should be scheduled in background
await _subscriptionStore.ForEachSubscriptionAsync((PushSubscription subscription) => {
await _subscriptionStore.ForEachSubscriptionAsync((WebPush.PushSubscription subscription) => {
// Fire-and-forget
_notificationService.SendNotificationAsync(subscription, pushMessage);
});

View File

@@ -13,7 +13,7 @@
<ItemGroup>
<PackageReference Include="Hangfire" Version="1.6.17" />
<PackageReference Include="Hangfire.MemoryStorage" Version="1.5.2" />
<PackageReference Include="Lib.Net.Http.EncryptedContentEncoding" Version="1.2.0" />
<PackageReference Include="Lib.Net.Http.WebPush" Version="1.3.0" />
<PackageReference Include="Microsoft.AspNetCore.App" Version="2.1.0-preview1-final" />
<PackageReference Include="AutoMapper" Version="6.2.2" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="3.2.0" />

34
server/PodNoms.Api.sln Normal file
View File

@@ -0,0 +1,34 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio 15
VisualStudioVersion = 15.0.26124.0
MinimumVisualStudioVersion = 15.0.26124.0
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PodNoms.Api", "PodNoms.Api.csproj", "{FA7A1ACB-9EF1-437D-A32D-B6051700C180}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{FA7A1ACB-9EF1-437D-A32D-B6051700C180}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FA7A1ACB-9EF1-437D-A32D-B6051700C180}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FA7A1ACB-9EF1-437D-A32D-B6051700C180}.Debug|x64.ActiveCfg = Debug|Any CPU
{FA7A1ACB-9EF1-437D-A32D-B6051700C180}.Debug|x64.Build.0 = Debug|Any CPU
{FA7A1ACB-9EF1-437D-A32D-B6051700C180}.Debug|x86.ActiveCfg = Debug|Any CPU
{FA7A1ACB-9EF1-437D-A32D-B6051700C180}.Debug|x86.Build.0 = Debug|Any CPU
{FA7A1ACB-9EF1-437D-A32D-B6051700C180}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FA7A1ACB-9EF1-437D-A32D-B6051700C180}.Release|Any CPU.Build.0 = Release|Any CPU
{FA7A1ACB-9EF1-437D-A32D-B6051700C180}.Release|x64.ActiveCfg = Release|Any CPU
{FA7A1ACB-9EF1-437D-A32D-B6051700C180}.Release|x64.Build.0 = Release|Any CPU
{FA7A1ACB-9EF1-437D-A32D-B6051700C180}.Release|x86.ActiveCfg = Release|Any CPU
{FA7A1ACB-9EF1-437D-A32D-B6051700C180}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

View File

@@ -19,14 +19,14 @@ namespace PodNoms.Api {
}
public static IWebHostBuilder CreateWebHostBuilder (string[] args) =>
WebHost.CreateDefaultBuilder (args)
.UseStartup<Startup> ()
.UseKestrel(options => {
options.Listen(IPAddress.Any, 5000);
if (isDevelopment){
options.Listen(IPAddress.Any, 5001, listenOptions => {
listenOptions.UseHttps("certs/dev2.podnoms.com.pfx", "secret");
});
}
});
.UseStartup<Startup> ();
// .UseKestrel(options => {
// options.Listen(IPAddress.Any, 5000);
// if (isDevelopment){
// options.Listen(IPAddress.Any, 5001, listenOptions => {
// listenOptions.UseHttps("certs/dev2.podnoms.com.pfx", "secret");
// });
// }
// });
}
}

View File

@@ -0,0 +1,7 @@
using System.Threading.Tasks;
namespace PodNoms.Api.Services.Jobs {
public interface INotifyJobCompleteService {
Task NotifyUser(string userId, string title, string body, string image);
}
}

View File

@@ -0,0 +1,27 @@
using System.Threading.Tasks;
using Lib.Net.Http.WebPush;
using PodNoms.Api.Services.Push;
using WebPush = Lib.Net.Http.WebPush;
namespace PodNoms.Api.Services.Jobs {
public class NotifyJobCompleteService : INotifyJobCompleteService {
private readonly IPushSubscriptionStore _subscriptionStore;
private readonly IPushNotificationService _notificationService;
public NotifyJobCompleteService(IPushSubscriptionStore subscriptionStore,
IPushNotificationService notificationService) {
this._notificationService = notificationService;
this._subscriptionStore = subscriptionStore;
}
public async Task NotifyUser(string userId, string title, string body, string image) {
WebPush.PushMessage pushMessage = new WebPush.PushMessage(body) {
Topic = title,
Urgency = PushMessageUrgency.Normal
};
await _subscriptionStore.ForEachSubscriptionAsync(userId, (WebPush.PushSubscription subscription) => {
_notificationService.SendNotificationAsync(subscription, pushMessage);
});
}
}
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Lib.Net.Http.WebPush;
using Microsoft.EntityFrameworkCore;
@@ -14,7 +15,9 @@ namespace PodNoms.Api.Services.Push.Data {
}
public Task StoreSubscriptionAsync(PushSubscription subscription) {
_context.Subscriptions.Add(new PushSubscriptionContext.PushSubscription(subscription));
PushSubscriptionContext.PushSubscription entity = new PushSubscriptionContext.PushSubscription(subscription);
var key = entity.Auth;
_context.Subscriptions.Add(entity);
return _context.SaveChangesAsync();
}
@@ -26,7 +29,9 @@ namespace PodNoms.Api.Services.Push.Data {
await _context.SaveChangesAsync();
}
public Task ForEachSubscriptionAsync(string uid, Action<PushSubscription> action) {
return _context.Subscriptions.Where(e => e.Auth == uid).AsNoTracking().ForEachAsync(action);
}
public Task ForEachSubscriptionAsync(Action<PushSubscription> action) {
return _context.Subscriptions.AsNoTracking().ForEachAsync(action);
}

View File

@@ -0,0 +1,16 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;
using PodNoms.Api.Services.Push.Data;
namespace PodNoms.Api.Services.Push.Extensions {
public static class ApplicationBuilderExtensions {
public static IApplicationBuilder UseSqlitePushSubscriptionStore(this IApplicationBuilder app) {
using (var serviceScope = app.ApplicationServices.GetRequiredService<IServiceScopeFactory>().CreateScope()) {
PushSubscriptionContext context = serviceScope.ServiceProvider.GetService<PushSubscriptionContext>();
context.Database.EnsureCreated();
}
return app;
}
}
}

View File

@@ -8,7 +8,7 @@ namespace PodNoms.Api.Services.Push.Extensions {
public static IServiceCollection AddPushServicePushNotificationService(this IServiceCollection services) {
services.AddMemoryCache();
services.AddSingleton<IVapidTokenCache, MemoryVapidTokenCache>();
services.AddSingleton<IPushNotificationService, PushServicePushNotificationService>();
services.AddSingleton<IPushNotificationService, FirebasePushNotificationService>();
return services;
}
}

View File

@@ -0,0 +1,43 @@
using System.Threading.Tasks;
using Lib.Net.Http.WebPush;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using Newtonsoft.Json;
namespace PodNoms.Api.Services.Push {
public class FirebasePushNotificationService : IPushNotificationService {
private readonly PushNotificationServiceOptions _options;
private readonly ILogger<PushServicePushNotificationService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
public string PublicKey => _options.PublicKey;
public FirebasePushNotificationService(IOptions<PushNotificationServiceOptions> optionsAccessor,
IHttpClientFactory httpClientFactory,
ILogger<PushServicePushNotificationService> logger) {
_options = optionsAccessor.Value;
_logger = logger;
_httpClientFactory = httpClientFactory;
}
public async Task SendNotificationAsync(PushSubscription subscription, PushMessage message) {
var fb_message = new {
notification = new {
title = message.Topic,
body = message.Content,
icon = _options.ImageUrl,
click_action = _options.ClickUrl,
},
to = subscription.Endpoint
};
var data = JsonConvert.SerializeObject(fb_message);
var content = new StringContent(data, Encoding.UTF8, "application/json");
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
"key",
$"={_options.PrivateKey}");
var result = await client.PostAsync(_options.PushUrl, content);
_logger.LogInformation("FCM: ", result.Content);
}
}
}

View File

@@ -5,6 +5,7 @@ using Lib.Net.Http.WebPush;
namespace PodNoms.Api.Services.Push {
public interface IPushSubscriptionStore {
Task StoreSubscriptionAsync(PushSubscription subscription);
Task ForEachSubscriptionAsync(string uid, Action<PushSubscription> action);
Task ForEachSubscriptionAsync(Action<PushSubscription> action);
}
}

View File

@@ -1,9 +1,11 @@
namespace PodNoms.Api.Services.Push {
public class PushNotificationServiceOptions {
public string Subject { get; set; }
public string PushUrl { get; set; }
public string ClickUrl { get; set; }
public string ImageUrl { get; set; }
public string PublicKey { get; set; }
public string PrivateKey { get; set; }
}
}

View File

@@ -104,6 +104,8 @@ namespace PodNoms.Api {
e.AddProfile(new MappingProvider(Configuration));
});
services.AddHttpClient();
services.AddAuthentication(options => {
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
@@ -178,6 +180,7 @@ namespace PodNoms.Api {
services.AddScoped<IPlaylistRepository, PlaylistRepository>();
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IUrlProcessService, UrlProcessService>();
services.AddScoped<INotifyJobCompleteService, NotifyJobCompleteService>();
services.AddScoped<IAudioUploadProcessService, AudioUploadProcessService>();
services.AddScoped<IMailSender, MailgunSender>();

BIN
server/pushsubscription.db Normal file

Binary file not shown.