PushActions.vala 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. using Inversion;
  2. using Spry;
  3. using Spry.Authentication;
  4. using Statum;
  5. using ClankerSaddle.Services;
  6. namespace ClankerSaddle {
  7. /**
  8. * Stores the browser's Web Push subscription for the signed-in
  9. * user: `endpoint`, `p256dh` and `auth` arrive
  10. * from the dashboard bell's subscribe flow
  11. * (`pushManager.subscribe` + the VAPID key from the page state).
  12. * Validated per {@link PushService.validate_subscription}; the
  13. * endpoint is unique per browser, so a re-subscribe replaces the
  14. * stored keys.
  15. */
  16. public class PushSubscribeAction : StatumAction {
  17. private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
  18. private PushService push = inject<PushService>();
  19. private UserService users = inject<UserService>();
  20. public override async DirectiveBuilder handle() throws GLib.Error {
  21. var guard = yield SpryAuth.require_login(directives(), request, users);
  22. if (guard != null) {
  23. return (!)guard;
  24. }
  25. string endpoint = Forms.field(request, "endpoint");
  26. string p256dh = Forms.field(request, "p256dh");
  27. string auth = Forms.field(request, "auth");
  28. string problem = PushService.validate_subscription(endpoint, p256dh, auth);
  29. if (problem != null) {
  30. return directives().notify("error", @"Could not enable notifications: $((!)problem)")
  31. .navigate("/");
  32. }
  33. var me = SpryAuth.current(request);
  34. try {
  35. yield push.upsert_subscription(db, me.user_id, endpoint, p256dh, auth);
  36. return directives().notify("success",
  37. "Notifications enabled — you'll be told about questions and reviews")
  38. .navigate("/");
  39. } catch (Error e) {
  40. return directives().notify("error",
  41. @"Could not store the subscription: $(e.message)").navigate("/");
  42. }
  43. }
  44. }
  45. /**
  46. * Removes the signed-in user's subscription for an endpoint (the
  47. * bell's off switch; the browser already unsubscribed its side).
  48. */
  49. public class PushUnsubscribeAction : StatumAction {
  50. private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
  51. private PushService push = inject<PushService>();
  52. private UserService users = inject<UserService>();
  53. public override async DirectiveBuilder handle() throws GLib.Error {
  54. var guard = yield SpryAuth.require_login(directives(), request, users);
  55. if (guard != null) {
  56. return (!)guard;
  57. }
  58. string endpoint = Forms.field(request, "endpoint");
  59. try {
  60. var me = SpryAuth.current(request);
  61. yield push.remove_subscription(db, me.user_id, endpoint);
  62. return directives().notify("success", "Notifications turned off").navigate("/");
  63. } catch (Error e) {
  64. return directives().notify("error",
  65. @"Could not turn notifications off: $(e.message)").navigate("/");
  66. }
  67. }
  68. }
  69. /**
  70. * Flips the signed-in user's per-task "Notify me" pref: opted-in
  71. * users receive the task's first start, failure
  72. * and completion on top of the global block events every
  73. * push-subscribed user gets.
  74. */
  75. /**
  76. * The bell's "Send test notification" probe (per-browser bell):
  77. * queues a REAL Web Push — full RFC 8291 + VAPID path — targeted at
  78. * the signed-in user's own subscriptions ({@link
  79. * PushService.send_test}), then reports the per-endpoint outcome
  80. * through the notify so the UI toasts the truth. This is the
  81. * missing end-to-end check: a green light here proves keys,
  82. * crypto, the push service and the browser's subscription all
  83. * line up, and a failure surfaces its actual reason instead of
  84. * silence. Also refreshes the dashboard slot so the bell's
  85. * endpoint fingerprints (and a pruned dead endpoint) are current.
  86. */
  87. public class PushTestAction : StatumAction {
  88. private TaskService task_service = inject<TaskService>();
  89. private SaddleService saddle_service = inject<SaddleService>();
  90. private TimelineService timeline_service = inject<TimelineService>();
  91. private NoteService note_service = inject<NoteService>();
  92. private InvercargillSql.Orm.OrmSession db = inject<InvercargillSql.Orm.OrmSession>();
  93. private PushService push = inject<PushService>();
  94. private UserService users = inject<UserService>();
  95. private ModelConfigService model_config = inject<ModelConfigService>();
  96. public override async DirectiveBuilder handle() throws GLib.Error {
  97. var guard = yield SpryAuth.require_login(directives(), request, users);
  98. if (guard != null) {
  99. return (!)guard;
  100. }
  101. var me = SpryAuth.current(request);
  102. try {
  103. var report = yield push.send_test(db, me.user_id);
  104. string message;
  105. string kind;
  106. if (report.failed == 0 && report.pruned == 0) {
  107. kind = "success";
  108. message = report.delivered > 1
  109. ? @"Test notification sent to $(report.delivered) browsers"
  110. : "Test notification sent";
  111. } else if (report.delivered > 0) {
  112. kind = "error";
  113. message = @"Test notification reached $(report.delivered) browser(s)"
  114. + @" but $(report.failed) failed";
  115. if (report.first_failure.length > 0) {
  116. message += @" ($(report.first_failure))";
  117. }
  118. } else {
  119. kind = "error";
  120. message = "Test notification failed";
  121. if (report.first_failure.length > 0) {
  122. message += @": $(report.first_failure)";
  123. }
  124. if (report.pruned > 0) {
  125. message += @" — $(report.pruned) dead endpoint(s) removed";
  126. }
  127. }
  128. var state = yield DashboardState.build(state_service, task_service,
  129. saddle_service, timeline_service, db, note_service, action_registry,
  130. push, me.user_id, model_config);
  131. return directives().notify(kind, message).set(state);
  132. } catch (Error e) {
  133. return directives().notify("error",
  134. @"Could not send the test notification: $(e.message)");
  135. }
  136. }
  137. }
  138. public class ToggleTaskNotifyAction : SealedTaskAction {
  139. private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
  140. private PushService push = inject<PushService>();
  141. private UserService users = inject<UserService>();
  142. public override async DirectiveBuilder handle() throws GLib.Error {
  143. var guard = yield SpryAuth.require_login(directives(), request, users);
  144. if (guard != null) {
  145. return (!)guard;
  146. }
  147. int64 task_id = sealed_task_id();
  148. if (task_id <= 0) {
  149. return directives().navigate("/tasks");
  150. }
  151. var me = SpryAuth.current(request);
  152. try {
  153. bool enabled = !(yield push.has_notify_pref(db, me.user_id, task_id));
  154. yield push.set_notify_pref(db, me.user_id, task_id, enabled);
  155. return directives().notify("success", enabled
  156. ? "You'll be notified when this task starts, fails or completes"
  157. : "Task notifications turned off")
  158. .navigate(@"/tasks/$(task_id)");
  159. } catch (Error e) {
  160. return directives().notify("error",
  161. @"Could not toggle notifications: $(e.message)")
  162. .navigate(@"/tasks/$(task_id)");
  163. }
  164. }
  165. }
  166. }