Skip to content

Commit 456dbde

Browse files
Merge branch 'aaronpk:main' into main
2 parents 45592f9 + fb9301d commit 456dbde

157 files changed

Lines changed: 13713 additions & 5640 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.env.example‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
# The APP_NAME is shown in the UI and web page title
44
APP_NAME=Meetable
55

6+
# The language the site is shown in. Translations live in resources/lang/{locale}
7+
# APP_LOCALE=en
8+
69
# Provide the base URL (no trailing slash) to where you've deployed the app
710
APP_URL=https://events.example.org
811
# APP_URL=http://localhost:8080
@@ -28,6 +31,10 @@ DB_PASSWORD=
2831
# Used by Docker to set the database root password
2932
DB_ROOT_PASSWORD=
3033

34+
# URLs from webmentions, imported events and profile photos are only fetched from public
35+
# IP addresses. Set this to true in development to allow fetching from private networks.
36+
# ALLOW_PRIVATE_NETWORK_REQUESTS=false
37+
3138
# Redis is a good solution for these, but you should be able to use any option provided by Laravel too.
3239
# The database option is probably the easiest to get started
3340
# Note: With docker all these are automatically overridden to use 'redis'!
@@ -65,6 +72,10 @@ AWS_DEFAULT_REGION=
6572
AWS_BUCKET=
6673
AWS_URL=
6774

75+
# Log every database query to storage/logs/sql, with values filled in. Only enable this
76+
# while debugging, since the log will contain secrets such as API tokens.
77+
# SQL_LOGGER_ALL_QUERIES_ENABLED=false
78+
6879
# For Heroku, use LOG_CHANNEL=stderr
6980
# LOG_CHANNEL=stack
7081
# LOG_CHANNEL=stderr
@@ -84,6 +95,10 @@ AUTH_METHOD=session
8495
# If you set DISCORD_ROLE_ID, the user will only be able to log in if they have this role in the server
8596
# You can enter more than one ID by separating them with a space
8697
# DISCORD_ROLE_ID=""
98+
# To post event reminders to Discord channels, copy the token from the Bot tab of the
99+
# Discord application here, and add https://events.example.org/discord/install as an OAuth2 redirect URL.
100+
# Then choose "Discord" from the menu after logging in to add the bot to your server.
101+
# DISCORD_BOT_TOKEN=
87102

88103
# GitHub
89104
# AUTH_METHOD=github

‎.env.testing‎

Lines changed: 0 additions & 1 deletion
This file was deleted.

‎.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,11 @@
55
/vendor
66
.env
77
.env.backup
8+
.env.testing
89
.phpunit.result.cache
910
Homestead.json
1011
Homestead.yaml
1112
npm-debug.log
1213
yarn-error.log
1314
.DS_Store
15+
.ghtoken

‎README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,9 +281,9 @@ OIDC_ADMIN_USERS=sub1 sub2
281281

282282
In this configuration, this project provides no authentication mechanism itself. Instead, it relies on the web server being able to authenticate users somehow, and setting an environment variable when users are logged in.
283283

284-
When the `Remote-User` header is present, this app considers users logged-in with the value of that header as their unique user ID, which is expected to be a URL. As long as the app sees a `Remote-User` header, users will be considered logged in.
284+
When the web server sets the `REMOTE_USER` variable, this app considers users logged-in with its value as their unique user ID, which is expected to be a URL. As long as the app sees `REMOTE_USER`, users will be considered logged in.
285285

286-
[Vouch Proxy](https://github.com/vouch/vouch-proxy) can offload authentication to an external OAuth service, and can be configured to set the HTTP `Remote-User` header that this project looks for.
286+
[Vouch Proxy](https://github.com/vouch/vouch-proxy) can offload authentication to an external OAuth service, and the web server can pass the user it returns to PHP as `REMOTE_USER`. A `Remote-User` request header is ignored, since anyone could send one.
287287

288288
Configure the application to use Vouch and tell it the hostname of your Vouch server.
289289

@@ -342,9 +342,10 @@ In the `location ~* \.php` block which proxies requests to the PHP handler, add
342342

343343
```
344344
fastcgi_param REMOTE_USER $auth_user;
345-
fastcgi_param HTTP_REMOTE_USER $auth_user;
346345
```
347346

347+
Older versions of these instructions also set `HTTP_REMOTE_USER`. The app no longer reads it, so make sure `REMOTE_USER` is set.
348+
348349
If you want your website to be visible even to logged-out users, make sure Vouch is configured with `publicAccess: true` to avoid sending back an error page when users are not logged in.
349350

350351
All users created in this mode will be created as regular users. You'll need to manually configure specific accounts as admin accounts in the database after they log in.

‎app/Console/Commands/FetchUserDetails.php‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
use Illuminate\Console\Command;
44
use Log;
55
use App\User;
6-
use p3k\XRay;
76

87
class FetchUserDetails extends Command {
98

@@ -21,7 +20,7 @@ public function handle() {
2120
foreach($users as $user) {
2221
$this->info('Fetching user info for '.$user->url);
2322

24-
$xray = new XRay();
23+
$xray = \App\Helpers\SafeHTTP::xray();
2524
$data = $xray->parse($user->url);
2625

2726
if(isset($data['data']['type']) && $data['data']['type'] == 'card') {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
namespace App\Console\Commands;
3+
use Illuminate\Console\Command;
4+
use Illuminate\Support\Facades\URL;
5+
use Illuminate\Support\Str;
6+
use App\User;
7+
8+
class PasskeyLink extends Command {
9+
10+
protected $signature = 'user:passkey-link {email : The email address or identifier of the user}';
11+
protected $description = 'Print a one-time link that lets a user register a new passkey';
12+
13+
const MINUTES = 30;
14+
15+
public function handle() {
16+
if(env('AUTH_METHOD') != 'session') {
17+
$this->error('Passkey links are only used when AUTH_METHOD=session');
18+
return 1;
19+
}
20+
21+
$email = $this->argument('email');
22+
$user = User::find_from_email($email) ?: User::where('identifier', $email)->first();
23+
24+
if(!$user) {
25+
$this->error('No user found for '.$email);
26+
return 1;
27+
}
28+
29+
// The nonce is part of the signed URL; the web request marks it as used
30+
$nonce = Str::random(40);
31+
32+
$url = URL::temporarySignedRoute('passkey-link', now()->addMinutes(self::MINUTES), [
33+
'user' => $user->id,
34+
'nonce' => $nonce,
35+
]);
36+
37+
$this->info('Open this link within '.self::MINUTES.' minutes to register a passkey for '.($user->name ?: $user->email).'. It can only be used once.');
38+
$this->line($url);
39+
return 0;
40+
}
41+
42+
}

‎app/Console/Commands/RefreshAllWebmentions.php‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
use Log, Storage;
66
use App\User, App\Event, App\Response;
77
use Image;
8-
use p3k\XRay;
98

109
class RefreshAllWebmentions extends Command {
1110

@@ -23,7 +22,7 @@ public function handle() {
2322

2423
$targetURL = $event->absolute_permalink();
2524

26-
$xray = new XRay();
25+
$xray = \App\Helpers\SafeHTTP::xray();
2726
$data = $xray->parse($webmention->source_url, [
2827
'target' => $targetURL,
2928
]);
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
namespace App\Console\Commands;
3+
use Illuminate\Console\Command;
4+
use Log;
5+
use App\DiscordNotification, App\DiscordNotificationSend;
6+
use App\Services\Discord, App\Services\DiscordException;
7+
use DateTime, DateTimeZone;
8+
9+
class SendDiscordNotifications extends Command {
10+
11+
protected $signature = 'discord:notify';
12+
protected $description = 'Post reminders to Discord channels for upcoming events';
13+
14+
public function handle() {
15+
16+
// This job is scheduled to run every minute.
17+
// For each tag/channel mapping, post about events starting within its time window.
18+
19+
if(!Discord::enabled() || !Discord::botConfigured())
20+
return;
21+
22+
$now = new DateTime('now', new DateTimeZone('UTC'));
23+
24+
$notifications = DiscordNotification::where('enabled', 1)->get();
25+
26+
foreach($notifications as $notification) {
27+
foreach($notification->eventsDue($now) as $event) {
28+
$this->info('Posting to #'.$notification->channel_name.' for '.$event->name.' '.$event->absolute_permalink());
29+
30+
try {
31+
$message_id = Discord::sendMessage($notification->channel_id, Discord::buildEventMessage($notification, $event));
32+
} catch(DiscordException $e) {
33+
// Nothing is recorded, so this will be retried next minute while the event is still upcoming
34+
Log::error('Discord notification '.$notification->id.' for event '.$event->id.' failed: '.$e->getMessage());
35+
$this->error($e->getMessage());
36+
continue;
37+
}
38+
39+
$send = new DiscordNotificationSend;
40+
$send->discord_notification_id = $notification->id;
41+
$send->event_id = $event->id;
42+
$send->event_start = DiscordNotification::eventStart($event);
43+
$send->discord_message_id = $message_id;
44+
$send->save();
45+
}
46+
}
47+
}
48+
49+
}

‎app/Console/Kernel.php‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class Kernel extends ConsoleKernel
2525
protected function schedule(Schedule $schedule)
2626
{
2727
$schedule->command('event:notify')->everyMinute();
28+
$schedule->command('discord:notify')->everyMinute()->withoutOverlapping();
2829
}
2930

3031
/**

‎app/DiscordNotification.php‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
<?php
2+
3+
namespace App;
4+
5+
use Illuminate\Database\Eloquent\Model;
6+
use DateTime, DateInterval;
7+
8+
class DiscordNotification extends Model
9+
{
10+
public static $TIME_UNITS = [
11+
'minutes' => 1,
12+
'hours' => 60,
13+
'days' => 1440,
14+
];
15+
16+
const MAX_MINUTES_BEFORE = 43200; // 30 days
17+
18+
public function sends() {
19+
return $this->hasMany('\App\DiscordNotificationSend');
20+
}
21+
22+
public function createdBy() {
23+
return $this->belongsTo('\App\User', 'created_by');
24+
}
25+
26+
public function lastModifiedBy() {
27+
return $this->belongsTo('\App\User', 'last_modified_by');
28+
}
29+
30+
// Events with this tag that should be announced
31+
public function eventsQuery() {
32+
$tag = $this->tag;
33+
return Event::whereHas('tags', function($query) use($tag){
34+
$query->where('tag', $tag);
35+
})
36+
->where('unlisted', 0)
37+
->where('is_template', 0)
38+
->whereNotIn('status', ['cancelled', 'postponed'])
39+
->whereNotNull('sort_date');
40+
}
41+
42+
// Events that start within the notification window and haven't been announced yet at their current start time
43+
public function eventsDue(DateTime $now) {
44+
$until = clone $now;
45+
$until->add(new DateInterval('PT'.$this->minutes_before.'M'));
46+
47+
$events = $this->eventsQuery()
48+
->where('sort_date', '>', $now->format('Y-m-d H:i:s'))
49+
->where('sort_date', '<=', $until->format('Y-m-d H:i:s'))
50+
->orderBy('sort_date', 'asc')
51+
->get();
52+
53+
return $events->filter(function($event){
54+
return !$this->wasSentFor($event);
55+
});
56+
}
57+
58+
public function nextEvent() {
59+
return $this->eventsQuery()
60+
->where('sort_date', '>', gmdate('Y-m-d H:i:s'))
61+
->orderBy('sort_date', 'asc')
62+
->first();
63+
}
64+
65+
public function wasSentFor(Event $event) {
66+
return $this->sends()
67+
->where('event_id', $event->id)
68+
->where('event_start', self::eventStart($event))
69+
->exists();
70+
}
71+
72+
// The event's UTC start as stored in sort_date
73+
public static function eventStart(Event $event) {
74+
if($event->sort_date instanceof \DateTimeInterface)
75+
return $event->sort_date->format('Y-m-d H:i:s');
76+
return (new DateTime($event->sort_date))->format('Y-m-d H:i:s');
77+
}
78+
79+
// Returns [number, unit] for displaying minutes_before in the largest unit it divides evenly into
80+
public function timeBeforeParts() {
81+
foreach(array_reverse(self::$TIME_UNITS) as $unit => $minutes) {
82+
if($this->minutes_before && $this->minutes_before % $minutes == 0)
83+
return [$this->minutes_before / $minutes, $unit];
84+
}
85+
return [$this->minutes_before, 'minutes'];
86+
}
87+
88+
public function timeBeforeText() {
89+
list($number, $unit) = $this->timeBeforeParts();
90+
return trans_choice('discord.durations.'.$unit, $number);
91+
}
92+
}

0 commit comments

Comments
 (0)