Skip to content

Commit

Permalink
Added API support for project invites
Browse files Browse the repository at this point in the history
  • Loading branch information
thommcgrath committed Nov 3, 2024
1 parent 95b43ad commit 76df0ed
Show file tree
Hide file tree
Showing 6 changed files with 332 additions and 0 deletions.
1 change: 1 addition & 0 deletions Website/api/v4/classes/DatabaseObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use BeaconCommon, BeaconRecordSet, BeaconUUID, Exception;

abstract class DatabaseObject {
const kPermissionNone = 0;
const kPermissionCreate = 1;
const kPermissionRead = 2;
const kPermissionUpdate = 4;
Expand Down
170 changes: 170 additions & 0 deletions Website/api/v4/classes/ProjectInvite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

namespace BeaconAPI\v4;
use BeaconCommon, BeaconEncryption, BeaconRecordSet, Exception, JsonSerializable;

class ProjectInvite extends DatabaseObject implements JsonSerializable {
use MutableDatabaseObject {
InitializeProperties as protected MutableDatabaseObjectInitializeProperties;
}

protected string $inviteCode;
protected string $projectId;
protected string $projectPassword;
protected string $role;
protected string $creatorId;
protected float $creationDate;
protected float $expirationDate;

protected function __construct(BeaconRecordSet $row) {
$this->inviteCode = $row->Field('invite_code');
$this->projectId = $row->Field('project_id');
$this->projectPassword = BeaconEncryption::RSADecrypt(BeaconCommon::GetGlobal('Beacon_Private_Key'), base64_decode($row->Field('project_password')));
$this->role = $row->Field('role');
$this->creatorId = $row->Field('creator_id');
$this->creationDate = floatval($row->Field('creation_date'));
$this->expirationDate = floatval($row->Field('expiration_date'));
}

public static function BuildDatabaseSchema(): DatabaseSchema {
$schema = new DatabaseSchema('public', 'project_invites', [
new DatabaseObjectProperty('inviteCode', ['primaryKey' => true, 'columnName' => 'invite_code', 'required' => false, 'editable' => DatabaseObjectProperty::kEditableNever]),
new DatabaseObjectProperty('projectId', ['columnName' => 'project_id']),
new DatabaseObjectProperty('projectPassword', ['columnName' => 'project_password']),
new DatabaseObjectProperty('role'),
new DatabaseObjectProperty('creatorId', ['columnName' => 'creator_id']),
new DatabaseObjectProperty('creationDate', ['columnName' => 'creation_date', 'accessor' => 'EXTRACT(EPOCH FROM %%TABLE%%.%%COLUMN%%)', 'setter' => 'TO_TIMESTAMP(%%PLACEHOLDER%%)']),
new DatabaseObjectProperty('expirationDate', ['columnName' => 'expiration_date', 'accessor' => 'EXTRACT(EPOCH FROM %%TABLE%%.%%COLUMN%%)', 'setter' => 'TO_TIMESTAMP(%%PLACEHOLDER%%)']),
]);
return $schema;
}

protected static function BuildSearchParameters(DatabaseSearchParameters $parameters, array $filters, bool $isNested): void {
$schema = static::DatabaseSchema();
$table = $schema->Table();
$parameters->AddFromFilter($schema, $filters, 'projectId');
$parameters->orderBy = $schema->Accessor('creationDate') . ' DESC';
$parameters->clauses[] = $schema->Table() . '.expiration_date >= CURRENT_TIMESTAMP';
}

public function jsonSerialize(): mixed {
return [
'inviteCode' => $this->inviteCode,
'redeemUrl' => BeaconCommon::AbsoluteUrl('/invite/'. urlencode($this->inviteCode)),
'projectId' => $this->projectId,
'role' => $this->role,
'creatorId' => $this->creatorId,
'creationDate' => $this->creationDate,
'expirationDate' => $this->expirationDate,
];
}

protected static function InitializeProperties(array &$properties): void {
static::MutableDatabaseObjectInitializeProperties($properties);
$properties['inviteCode'] = BeaconCommon::GenerateRandomKey(8, '23456789ABCDEFGHJKMNPRSTUVWXYZ');
$properties['creatorId'] = $properties['userId'];
$properties['creationDate'] = time();
$properties['expirationDate'] = time() + 86400;

if (isset($properties['projectPassword'])) {
$properties['projectPassword'] = base64_encode(BeaconEncryption::RSAEncrypt(BeaconEncryption::ExtractPublicKey(BeaconCommon::GetGlobal('Beacon_Private_Key')), base64_decode($properties['projectPassword'])));
}
}

public function InviteCode(): string {
return $this->inviteCode;
}

public function ProjectId(): string {
return $this->projectId;
}

public function ProjectPassword(): string {
return $this->projectPassword;
}

public function Role(): string {
return $this->role;
}

public function CreatorId(): string {
return $this->creatorId;
}

public function CreationDate(): float {
return $this->creationDate;
}

public function ExpirationDate(): float {
return $this->expirationDate;
}

public function IsExpired(): bool {
return $this->expirationDate < time();
}

protected static function UserIsAdmin(User $user, string $projectId): bool {
if (BeaconCommon::IsUUID($projectId) === false) {
return false;
}

$database = BeaconCommon::Database();
$rows = $database->Query('SELECT role FROM public.project_members WHERE user_id = $1 AND project_id = $2;', $user->UserId(), $projectId);
if ($rows->RecordCount() !== 1) {
return false;
}
$role = $rows->Field('role');

return $role === ProjectMember::kRoleOwner || $role === ProjectMember::kRoleAdmin;
}

public static function GetNewObjectPermissionsForUser(User $user, ?array $newObjectProperties): int {
if (is_null($newObjectProperties) || isset($newObjectProperties['projectId']) === false) {
return DatabaseObject::kPermissionNone;
}

$projectId = strval($newObjectProperties['projectId']);
$member = ProjectMember::Fetch($projectId, $user->UserId());
if (is_null($member)) {
return DatabaseObject::kPermissionNone;
}

try {
$desiredPermissions = ProjectMember::PermissionsForRole($newObjectProperties['role']);
} catch (Exception $err) {
return DatabaseObject::kPermissionNone;
}

if ($desiredPermissions < $member->Permissions()) {
return DatabaseObject::kPermissionAll;
} else {
return DatabaseObject::kPermissionNone;
}
}

public function GetPermissionsForUser(User $user): int {
$member = ProjectMember::Fetch($this->projectId, $user->UserId());
if (is_null($member)) {
return DatabaseObject::kPermissionNone;
}
try {
$rolePermissions = ProjectMember::PermissionsForRole($this->role);
} catch (Exception $err) {
return DatabaseObject::kPermissionNone;
}
if ($rolePermissions < $member->Permissions()) {
return DatabaseObject::kPermissionAll;
} else {
return DatabaseObject::kPermissionNone;
}
}

public static function CleanupExpired(): void {
$database = BeaconCommon::Database();
$database->BeginTransaction();
$database->Query('DELETE FROM public.project_invites WHERE expiration_date < CURRENT_TIMESTAMP;');
$database->Commit();
}
}

?>
32 changes: 32 additions & 0 deletions Website/api/v4/classes/ProjectMember.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ class ProjectMember implements JsonSerializable {
public const kRoleEditor = 'Editor';
public const kRoleGuest = 'Guest';

public const kPermissionsOwner = 90;
public const kPermissionsAdmin = 80;
public const kPermissionsEditor = 70;
public const kPermissionsGuest = 10;

protected string $projectId;
protected string $userId;
protected string $username;
Expand Down Expand Up @@ -107,6 +112,18 @@ public function Fingerprint(): ?string {
return $this->fingerprint;
}

public function IsOwner(): bool {
return $this->permissions >= self::kPermissionsOwner;
}

public function IsAdmin(): bool {
return $this->permissions >= self::kPermissionsAdmin;
}

public function IsEditor(): bool {
return $this->permissions >= self::kPermissionsEditor;
}

public function jsonSerialize(): mixed {
return [
'projectId' => $this->projectId,
Expand Down Expand Up @@ -141,6 +158,21 @@ public static function GenerateFingerprint(string $userId, string $username, str

return base64_encode(hash('sha3-256', implode('', $pieces), true));
}

public static function PermissionsForRole(string $role): int {
switch ($role) {
case self::kRoleOwner:
return self::kPermissionsOwner;
case self::kRoleAdmin:
return self::kPermissionsAdmin;
case self::kRoleEditor:
return self::kPermissionsEditor;
case self::kRoleGuest:
return self::kPermissionsGuest;
default:
throw new Exception('Unknown role ' . $role);
}
}
}

?>
1 change: 1 addition & 0 deletions Website/api/v4/index.php
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@
DatabaseObjectManager::RegisterRoutes('BeaconAPI\v4\Palworld\ConfigOption', 'palworld/configOptions', 'configOptionId');
DatabaseObjectManager::RegisterRoutes('BeaconAPI\v4\Palworld\GameVariable', 'palworld/gameVariables', 'key');
DatabaseObjectManager::RegisterRoutes('BeaconAPI\v4\SDTD\ConfigOption', '7dtd/configOptions', 'configOptionId');
DatabaseObjectManager::RegisterRoutes('BeaconAPI\v4\ProjectInvite', 'projectInvites', 'inviteCode');

Core::HandleRequest(dirname(__FILE__) . '/requests');

Expand Down
2 changes: 2 additions & 0 deletions Website/conf/www.conf
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ rewrite "^/help/math.php$" /help/ permanent;
rewrite "^/help/spec.php$" /help/ permanent;
rewrite "^/redeem/{0,1}$" /account/redeem permanent;
rewrite "^/redeem/([a-zA-Z0-9]{9})/{0,1}$" /account/redeem/$1 permanent;
rewrite "^/invite/(.{8})/{0,1}$" /account/invite/$1 permanent;

rewrite "^/object\.php/([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12})$" /games/ark/blueprint.php?objectId=$1 last;
rewrite "^/object/([0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-4[0-9A-Fa-f]{3}-[89ABab][0-9A-Fa-f]{3}-[0-9A-Fa-f]{12})$" /games/ark/blueprint.php?objectId=$1 last;
Expand Down Expand Up @@ -87,6 +88,7 @@ rewrite "^/account/auth$" /account/auth/redeem.php last;
rewrite "^/account/login/verify(\.php)?$" /account/auth/verify.php last;
rewrite "^/download(/?((index)?\.php)?)?$" /download/index.php last;
rewrite "^/tools/breeding(\.php)?$" /Games/Ark/Breeding permanent;
rewrite "^/account/invite/(.{8})/?$" /account/invite.php?code=$1 last;

rewrite "(?i)^/Games/?$" /games/index.php last;

Expand Down
126 changes: 126 additions & 0 deletions Website/www/account/invite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php

require(dirname(__FILE__, 3) . '/framework/loader.php');

use BeaconAPI\v4\{Project, ProjectInvite, ProjectMember, Session};

if (isset($_REQUEST['code'])) {
$code = substr($_REQUEST['code'], 0, 9);
} else {
$code = '';
}

BeaconCommon::StartSession();
$session = BeaconCommon::GetSession();
$is_logged_in = is_null($session) === false;
$user = $is_logged_in ? $session->User() : null;

header('Cache-Control: no-cache');

$database = BeaconCommon::Database();

BeaconTemplate::AddStylesheet(BeaconCommon::AssetURI('account.css'));

$process_step = 'start';
if (isset($_REQUEST['process'])) {
$process_step = strtolower($_REQUEST['process']);
}

?><div id="invite_form" class="reduced-width">
<h1>Beacon Project Invite</h1>
<?php

switch ($process_step) {
case 'invite-final':
case 'invite-confirm':
RedeemCode($code, $process_step === 'invite-final');
break;
default:
ShowForm($code);
break;
}

?>
</div><?php

function RedeemCode(string $code, bool $confirmed): void {
global $user;

if (is_null($user) || is_null($user->EmailId())) {
$return = BeaconCommon::AbsoluteURL('/account/invite/' . urlencode($code) . '?process=invite-confirm');
BeaconCommon::Redirect('/account/login/?return=' . urlencode($return));
return;
}

$invite = ProjectInvite::Fetch($code);
if (is_null($invite) || $invite->IsExpired()) {
ShowForm($code, 'This project invite code is not valid. It may have been deleted or it has expired. Invite codes expire after 24 hours.');
return;
}

$member = ProjectMember::Fetch($invite->ProjectId(), $user->UserId());
if (is_null($member) === false) {
ShowForm($code, 'You are already a member of this project. You cannot accept another invite for the same project.');
return;
}

if ($confirmed) {
$database = BeaconCommon::Database();
$database->BeginTransaction();
try {
$encryptedPassword = base64_encode(BeaconEncryption::RSAEncrypt($user->PublicKey(), $invite->ProjectPassword()));
$fingerprint = ProjectMember::GenerateFingerprint($user->UserId(), $user->Username(), $user->PublicKey(), $invite->ProjectPassword());
$member = ProjectMember::Create($invite->ProjectId(), $user->UserId(), $invite->Role(), $encryptedPassword, $fingerprint);
} catch (Exception $err) {
$database->Rollback();
ShowForm($code, 'Could not add user to project: ' . htmlentities($err->getMessage()));
return;
}
try {
$invite->Delete();
} catch (Exception $err) {
$database->Rollback();
ShowForm($code, 'Could not delete invite after usage: ' . htmlentities($err->getMessage()));
return;
}
$database->Commit();

echo '<p class="text-center"><span class="text-blue">Project invite accepted!</span><br>You are now a member of the project.</p>';
} else {
// Show confirmation
$project = Project::Fetch($invite->ProjectId());
echo '<form action="/account/invite" method="post"><input type="hidden" name="process" value="invite-final"><input type="hidden" name="code" value="' . htmlentities($code) . '">';
echo '<p>This invite code will add you to the project &quot;' . htmlentities($project->Title()) . '&quot;</p>';
echo '<div class="double-group"><div>&nbsp;</div><div><div class="button-group"><div><a href="/account/invite" class="button">Cancel</a></div><div><input type="submit" value="Accept Invite"></div></div></div>';
echo '</form>';
}
}

function ShowForm(string $code, ?string $error = null): void {
global $user;

$invite = ProjectInvite::Fetch($code);
if (is_null($invite) || $invite->IsExpired()) {
echo '<p class="text-center text-red">This project invite code is not valid. It may have been deleted or it has expired. Invite codes expire after 24 hours.</p>';
return;
}

$project = Project::Fetch($invite->ProjectId());

echo '<form action="/account/invite" method="post"><input type="hidden" name="process" value="invite-confirm"><p>You have been invited to join the Beacon Project &quot;' . htmlentities($project->Title()) . '&quot;</p>';

if (empty($error) === false) {
echo '<p class="text-center text-red">' . htmlentities($error) . '</p>';
}

echo '<div class="floating-label"><input type="text" class="text-field" name="code" placeholder="Code" minlength="9" maxlength="9" value="' . htmlentities($code) . '"><label>Invite Code</label></div>';

if (is_null($user)) {
echo '<p class="text-right bold">You will be be asked to log in or create an account to accept this invite code.</p>';
echo '<p class="text-right"><input type="submit" value="Sign In and Accept"></p>';
} else {
echo '<p class="text-right"><input type="submit" value="Accept"></p>';
}
}

?>

0 comments on commit 76df0ed

Please sign in to comment.