-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Added API support for project invites
- Loading branch information
1 parent
95b43ad
commit 76df0ed
Showing
6 changed files
with
332 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
} | ||
} | ||
|
||
?> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 "' . htmlentities($project->Title()) . '"</p>'; | ||
echo '<div class="double-group"><div> </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 "' . htmlentities($project->Title()) . '"</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>'; | ||
} | ||
} | ||
|
||
?> |