Files
itflow/functions/share.php
johnnyq 9457442804 Emails stage 3: every email now goes through emailQueue and the shared layout - the client portal invite sends a link to set a password (or to sign in with Microsoft) instead of the password itself, offered on New Contact as well as Edit through portalMailInvite, with Forgot password, password changed and two-step sign-in emails through portalMailPasswordReset and portalMailAccountChange; shared link and code emails with the note quoted and the whole link printed under the button, replacing shareMailHtml and shareQueueMail; calendar invitations, updates and cancellations with when, repeats, where, the description and a join button; Email on the contacts and clients lists in the layout from one of the configured mailboxes with an optional greeting and send time, leaving out archived contacts and contacts without an address, each address once, nothing queued without mail set up, through emailBulkSend and emailBulkPost with a shared bulk_email_fields window; Admin > Mail > Previews shows all 52 emails built by their real senders on sample records inside a rolled-back transaction with emailQueue capturing and custom actions off, with computer and phone widths and Send me a copy; emailHtml gains content and button show_url, emailQueue gains queued_at and always so billing still queues as before, plus emailMailboxes and emailTestSend; ticketMailCreated, ticketMailReply, ticketMailWatchersAdded, ticketMailTaskApproval, ticketMailClosedReply, ticketSlaAlert and billingMailCardSaved moved into functions; fixes Email on the contacts list queuing mail to contacts with no address, the clients list including archived contacts, and both taking any From address posted; changelog updated
2026-10-17 23:26:25 -04:00

906 lines
40 KiB
PHP

<?php
/*
* Shared links - a link for someone outside to open a login, a secure note,
* a document or a file, or to send a login back to us (a request).
*
* A link carries one secret, after the # (guest/share.php?id=12#secret), so
* it never reaches the server's access log, a proxy or a Referer header -
* js/guest_share.js reads it and posts it to guest/share_ajax.php. The table
* keeps only a hash of it (item_key) to find the link. Anything secret in the
* link - the login, the note, what a client sends back - is encrypted with
* AES-256-GCM under a key made from that secret (shareContentKey()), which is
* never stored: the database alone opens nothing.
*
* Opening a link takes a click (Reveal), so the link checkers in mail
* filters and chat apps that fetch every URL cannot use up a one-time link.
* The browser that opened it may open it again, and keeps getting 2FA codes,
* for SHARE_GRACE_MINUTES; after that the secret is wiped (shareSweep()).
* A passphrase, or a code emailed to the recipient, can be asked for first;
* SHARE_ATTEMPTS wrong answers lock the link.
*
* A link made before 2.9.0 (item_version 1) carried key= and ek= in the query
* string, with the login under aes-128-cbc - those keep working until they
* expire (at most a month after the update).
*
* Every link stays listed after it ends - used up, expired, turned off,
* locked - for SHARE_KEEP_DAYS, with who opened it and when from the audit
* log (log_type 'Share', log_entity_id the link).
*/
/** The browser that opened a link may open it again, and keeps getting 2FA codes, for this long. */
const SHARE_GRACE_MINUTES = 30;
/** Wrong passphrases or codes before a link locks. */
const SHARE_ATTEMPTS = 5;
/** An emailed code lasts this long, and another can be sent after SHARE_CODE_RESEND_SECONDS. */
const SHARE_CODE_MINUTES = 10;
const SHARE_CODE_RESEND_SECONDS = 60;
/** Ended links stay listed this long; a login a client sent stays this long unsaved. */
const SHARE_KEEP_DAYS = 90;
const SHARE_RECEIVED_KEEP_DAYS = 30;
/** The longest a secure note may be, and a passphrase the shortest. */
const SHARE_NOTE_MAX = 10000;
const SHARE_PASSPHRASE_MIN = 6;
/** What can be shared: what it is called, its icon, and whether it holds a secret. */
function shareTypes() {
return [
'Credential' => ['label' => 'Login', 'icon' => 'fa-key', 'secret' => true],
'Note' => ['label' => 'Secure note', 'icon' => 'fa-user-secret', 'secret' => true],
'Document' => ['label' => 'Document', 'icon' => 'fa-file-alt', 'secret' => false],
'File' => ['label' => 'File', 'icon' => 'fa-paperclip', 'secret' => false],
'Request' => ['label' => 'Login request', 'icon' => 'fa-inbox', 'secret' => false],
];
}
/** How long a link may last, in hours => label, up to the admin's limit (Settings > Security). */
function shareExpiryOptions() {
global $config_share_max_hours;
$max = intval($config_share_max_hours ?? 720) ?: 720;
$options = [1 => '1 hour', 4 => '4 hours', 24 => '1 day', 72 => '3 days', 168 => '1 week', 336 => '2 weeks', 720 => '30 days', 2160 => '90 days'];
return array_filter($options, function ($hours) use ($max) {
return $hours <= $max;
}, ARRAY_FILTER_USE_KEY);
}
/** What the admin may set as the longest a link can last, in hours => label. */
function shareMaxOptions() {
return [24 => '1 day', 72 => '3 days', 168 => '1 week', 336 => '2 weeks', 720 => '30 days', 2160 => '90 days'];
}
/** How many times a link may be opened => label; 0 is until it expires. */
function shareViewOptions() {
return [1 => 'Once', 2 => 'Twice', 3 => '3 times', 5 => '5 times', 10 => '10 times', 0 => 'Until it expires'];
}
/** The window's starting choices for a type - a short, single-use link for anything secret. */
function shareDefaults($type) {
$expiry = ['Credential' => 24, 'Note' => 24, 'Request' => 72, 'Document' => 168, 'File' => 168][$type] ?? 24;
// The longest choice the admin's limit leaves that is no longer than that
$hours = array_keys(shareExpiryOptions());
$fit = array_filter($hours, function ($option) use ($expiry) {
return $option <= $expiry;
});
$expiry = $fit ? max($fit) : min($hours);
return [
'expires' => $expiry,
'views' => in_array($type, ['Document', 'File']) ? 0 : 1,
];
}
/** Must a link of this type have a passphrase or an emailed code? (Settings > Security) */
function shareNeedsProtection($type) {
global $config_share_require_protection;
return !empty($config_share_require_protection) && !empty(shareTypes()[$type]['secret']);
}
/**
* May this agent see links of this type (the list, a link's quick look)?
* Logins and requests are for whoever may see credentials.
*/
function shareCanSee($type) {
if (lookupUserPermission('module_support') < 1) {
return false;
}
if (in_array($type, ['Credential', 'Request'])) {
return lookupUserPermission('module_credential') >= 1;
}
return true;
}
/**
* May this agent make, or turn off, a link of this type? Sharing a login
* hands out what reading one does; asking for one makes a credential.
*/
function shareCanMake($type) {
if (!isset(shareTypes()[$type]) || lookupUserPermission('module_support') < 2) {
return false;
}
if ($type === 'Credential') {
return lookupUserPermission('module_credential') >= 1;
}
if ($type === 'Request') {
return lookupUserPermission('module_credential') >= 2;
}
return true;
}
/** The types this agent may see, as an SQL list for item_type IN (...). */
function shareVisibleTypesSql() {
$types = array_filter(array_keys(shareTypes()), 'shareCanSee');
return $types ? "'" . implode("','", $types) . "'" : "''";
}
/** Is mail set up to send with - a mail provider, and a system mailbox? */
function shareMailReady() {
return emailCapturing() || (emailReady() && filter_var(emailFrom()['email'], FILTER_VALIDATE_EMAIL));
}
/** A new link secret - 256 bits, URL-safe. */
function shareSecret() {
return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
}
/** How a link's secret is kept - only its hash. */
function shareKeyHash($secret) {
return hash('sha256', (string) $secret);
}
/** The key a link's secret content is encrypted with - made from the secret, never stored. */
function shareContentKey($secret) {
return hash_hmac('sha256', 'itflow share content', (string) $secret, true);
}
/** Encrypt an array for a link: AES-256-GCM, base64 of iv . tag . ciphertext. */
function shareSeal(array $data, $key) {
$iv = random_bytes(12);
$tag = '';
$ciphertext = openssl_encrypt(json_encode($data), 'aes-256-gcm', $key, OPENSSL_RAW_DATA, $iv, $tag);
return base64_encode($iv . $tag . $ciphertext);
}
/** The array shareSeal() made, or null when the key is wrong or it has been tampered with. */
function shareUnseal($payload, $key) {
$raw = base64_decode((string) $payload, true);
if ($raw === false || strlen($raw) < 29) {
return null;
}
$plain = openssl_decrypt(substr($raw, 28), 'aes-256-gcm', $key, OPENSSL_RAW_DATA, substr($raw, 0, 12), substr($raw, 12, 16));
if ($plain === false) {
return null;
}
$data = json_decode($plain, true);
return is_array($data) ? $data : null;
}
/** The link to send. */
function shareUrl($item_id, $secret) {
global $config_base_url;
return "https://$config_base_url/guest/share.php?id=" . intval($item_id) . "#$secret";
}
/** A QR code of the link, as inline SVG - '' without GD, which libs/barcode needs. */
function shareQrSvg($url) {
if (!extension_loaded('gd')) {
return '';
}
require_once dirname(__DIR__) . '/libs/barcode/barcode.php';
$generator = new barcode_generator();
$svg = (string) $generator->render_svg('qr', $url, ['w' => 180, 'h' => 180, 'p' => 0]);
$svg = preg_replace('/^<\?xml[^>]*>\s*/', '', $svg);
return preg_replace('/<svg /', '<svg class="itflow-share-qr" role="img" aria-label="QR code of the link" ', $svg, 1);
}
/** One link by id, with who made it and the client - raw, or null. */
function shareRow($item_id) {
global $mysqli;
$item_id = intval($item_id);
if (!$item_id) {
return null;
}
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT shared_items.*, client_name, creator.user_name AS creator_name, ender.user_name AS ender_name
FROM shared_items
LEFT JOIN clients ON client_id = item_client_id
LEFT JOIN users creator ON creator.user_id = item_created_by
LEFT JOIN users ender ON ender.user_id = item_ended_by
WHERE item_id = $item_id LIMIT 1"));
return $row ?: null;
}
/** The link this id and secret open, or null - for the guest side. */
function shareFind($item_id, $secret) {
global $mysqli;
$item_id = intval($item_id);
$secret = (string) $secret;
if (!$item_id || $secret === '' || strlen($secret) > 200) {
return null;
}
$hash = shareKeyHash($secret);
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT shared_items.*, client_name, creator.user_name AS creator_name
FROM shared_items
LEFT JOIN clients ON client_id = item_client_id
LEFT JOIN users creator ON creator.user_id = item_created_by
WHERE item_id = $item_id AND item_key = '$hash' LIMIT 1"));
return $row ?: null;
}
/**
* Where a link is: active, waiting (a request not answered yet), received
* (answered, not dealt with), used (no views left), expired, or ended - off,
* locked, destroyed (by the person it was for), saved or discarded (a request).
*/
function shareState($row) {
$ended = [
'Revoked' => 'revoked', 'Locked' => 'locked', 'Destroyed' => 'destroyed',
'Saved' => 'saved', 'Discarded' => 'discarded',
];
if (!empty($row['item_end_reason'])) {
return $ended[$row['item_end_reason']] ?? 'revoked';
}
if (empty($row['item_active'])) {
return 'revoked';
}
if ($row['item_type'] === 'Request' && !empty($row['item_received_at'])) {
return 'received';
}
if (strtotime((string) $row['item_expire_at']) <= time()) {
return 'expired';
}
$limit = intval($row['item_view_limit']);
if ($limit > 0 && intval($row['item_views']) >= $limit) {
return 'used';
}
return $row['item_type'] === 'Request' ? 'waiting' : 'active';
}
/** Each state's label and badge. */
function shareStates() {
return [
'active' => ['Active', 'text-bg-success'],
'waiting' => ['Waiting', 'text-bg-info'],
'received' => ['Received', 'text-bg-warning'],
'used' => ['Used', 'text-bg-secondary'],
'expired' => ['Expired', 'text-bg-secondary'],
'revoked' => ['Turned off', 'text-bg-secondary'],
'locked' => ['Locked', 'text-bg-danger'],
'destroyed' => ['Destroyed', 'text-bg-secondary'],
'saved' => ['Saved', 'text-bg-success'],
'discarded' => ['Discarded', 'text-bg-secondary'],
];
}
/** A link's state as a badge. */
function shareStateBadge($row) {
$state = shareState($row);
[$label, $class] = shareStates()[$state];
return '<span class="badge rounded-pill ' . $class . ' fw-normal">' . $label . '</span>';
}
/** SQL for a link that can still be opened (or, for a request, answered). Columns unqualified. */
function shareOpenSql() {
return "item_active = 1 AND item_ended_at IS NULL AND item_received_at IS NULL AND item_expire_at > NOW() AND (item_view_limit = 0 OR item_views < item_view_limit)";
}
/** SQL for a link still to do something about: open, or a request answered and not dealt with. */
function shareLiveSql() {
return "item_ended_at IS NULL AND ((" . shareOpenSql() . ") OR (item_type = 'Request' AND item_received_at IS NOT NULL))";
}
/** "2 of 5 views", "Not opened", "Opened 3 times". */
function shareViewsText($row) {
$views = intval($row['item_views']);
$limit = intval($row['item_view_limit']);
if ($row['item_type'] === 'Request') {
return !empty($row['item_received_at']) ? 'Answered' : 'Not answered';
}
if ($limit > 0) {
return "$views of $limit " . ($limit === 1 ? 'view' : 'views');
}
return $views ? "Opened $views " . ($views === 1 ? 'time' : 'times') : 'Not opened';
}
/** What the link asks for before it opens: '', 'passphrase' or 'code'. */
function shareProtection($row) {
if (!empty($row['item_passphrase'])) {
return 'passphrase';
}
return !empty($row['item_verify_email']) ? 'code' : '';
}
/** The name to show for a link - its own, or its type's. */
function shareName($row) {
$name = trim((string) ($row['item_name'] ?? ''));
return $name !== '' ? $name : (shareTypes()[$row['item_type']]['label'] ?? 'Shared item');
}
/** The name of the record a link shares, as it is now - null when it is gone. */
function shareItemLive($row) {
global $mysqli;
$id = intval($row['item_related_id']);
$client_id = intval($row['item_client_id']);
$table = [
'Credential' => ['credentials', 'credential_id', 'credential_name', 'credential_client_id'],
'Document' => ['documents', 'document_id', 'document_name', 'document_client_id'],
'File' => ['files', 'file_id', 'file_name', 'file_client_id'],
][$row['item_type']] ?? null;
if (!$table || !$id) {
return null;
}
[$name_table, $id_column, $name_column, $client_column] = $table;
$found = mysqli_fetch_row(mysqli_query($mysqli, "SELECT $name_column FROM $name_table WHERE $id_column = $id AND $client_column = $client_id LIMIT 1"));
return $found ? (string) $found[0] : null;
}
/**
* Make a link. $in: client_id, type, related_id (Credential, Document,
* File), name (Note, Request), text (Note), recipient, send (email it),
* expires (hours), views, protect ('', 'passphrase', 'code'), passphrase,
* otp (share the login's 2FA codes), notify, note. The caller has checked
* the agent may (shareCanMake) and the client (enforceClientAccess).
*
* Returns [item_id, url, emailed, expire_at, passphrase, protect, recipient,
* views] - the url and passphrase only this once - or null with $error set.
*/
function shareCreate(array $in, &$error = '') {
global $mysqli, $session_user_id, $session_name;
$error = '';
$type = (string) ($in['type'] ?? '');
$client_id = intval($in['client_id'] ?? 0);
$related_id = intval($in['related_id'] ?? 0);
if (!isset(shareTypes()[$type])) {
$error = 'That cannot be shared.';
return null;
}
if (!$client_id || !getFieldById('clients', $client_id, 'client_name')) {
$error = 'That client no longer exists.';
return null;
}
$expires = intval($in['expires'] ?? 0);
if (!isset(shareExpiryOptions()[$expires])) {
$error = 'Pick how long the link lasts.';
return null;
}
$views = $type === 'Request' ? 1 : intval($in['views'] ?? 1);
if (!isset(shareViewOptions()[$views])) {
$error = 'Pick how many times the link can be opened.';
return null;
}
$recipient = trim((string) ($in['recipient'] ?? ''));
if (mb_strlen($recipient) > 250) {
$error = 'That recipient is too long.';
return null;
}
$recipient_email = filter_var($recipient, FILTER_VALIDATE_EMAIL) ? $recipient : '';
$send = !empty($in['send']);
if ($send && !$recipient_email) {
$error = 'To email the link, pick a contact or type an email address.';
return null;
}
if ($send && !shareMailReady()) {
$error = 'Mail is not set up, so the link cannot be emailed - copy it instead.';
return null;
}
$protect = (string) ($in['protect'] ?? '');
if (!in_array($protect, ['', 'passphrase', 'code'], true)) {
$protect = '';
}
if ($protect === '' && shareNeedsProtection($type)) {
$error = 'Your sharing policy needs a passphrase or an emailed code on passwords and secure notes.';
return null;
}
$passphrase = trim((string) ($in['passphrase'] ?? ''));
if ($protect === 'passphrase' && mb_strlen($passphrase) < SHARE_PASSPHRASE_MIN) {
$error = 'The passphrase needs at least ' . SHARE_PASSPHRASE_MIN . ' characters.';
return null;
}
if ($protect === 'passphrase' && mb_strlen($passphrase) > 200) {
$error = 'That passphrase is too long.';
return null;
}
if ($protect === 'code' && !$recipient_email) {
$error = 'An emailed code needs the recipient\'s email address.';
return null;
}
if ($protect === 'code' && !shareMailReady()) {
$error = 'Mail is not set up, so a code cannot be emailed - use a passphrase instead.';
return null;
}
$note = trim((string) ($in['note'] ?? ''));
if (mb_strlen($note) > 1000) {
$error = 'The note for them is too long - keep it under 1000 characters.';
return null;
}
$secret = shareSecret();
$key = shareContentKey($secret);
$payload = null;
$vault_key = null;
$name = '';
if ($type === 'Credential') {
$credential = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT credential_name, credential_uri, credential_username, credential_password, credential_otp_secret
FROM credentials WHERE credential_id = $related_id AND credential_client_id = $client_id LIMIT 1"));
if (!$credential) {
$error = 'That credential no longer exists.';
return null;
}
$username = decryptCredentialEntry($credential['credential_username']);
$password = decryptCredentialEntry($credential['credential_password']);
if ($password === false || $username === false || encryptCredentialEntry('probe') === false) {
$error = 'Your vault session has expired, so the login could not be read - sign out and back in, then try again.';
return null;
}
$name = (string) $credential['credential_name'];
$otp = !empty($in['otp']) ? (string) credentialOtpSeed($credential['credential_otp_secret']) : '';
$payload = shareSeal([
'name' => $name,
'uri' => (string) $credential['credential_uri'],
'username' => (string) $username,
'password' => (string) $password,
'otp' => $otp,
], $key);
} elseif ($type === 'Document') {
$name = mysqli_fetch_row(mysqli_query($mysqli, "SELECT document_name FROM documents WHERE document_id = $related_id AND document_client_id = $client_id LIMIT 1"))[0] ?? null;
if ($name === null) {
$error = 'That document no longer exists.';
return null;
}
} elseif ($type === 'File') {
$name = mysqli_fetch_row(mysqli_query($mysqli, "SELECT file_name FROM files WHERE file_id = $related_id AND file_client_id = $client_id LIMIT 1"))[0] ?? null;
if ($name === null) {
$error = 'That file no longer exists.';
return null;
}
} elseif ($type === 'Note') {
$related_id = 0;
$text = rtrim((string) ($in['text'] ?? ''));
if (trim($text) === '') {
$error = 'Type what you want to send.';
return null;
}
if (mb_strlen($text) > SHARE_NOTE_MAX) {
$error = 'That is too long for a secure note - keep it under ' . number_format(SHARE_NOTE_MAX) . ' characters, or share a document.';
return null;
}
$name = trim((string) ($in['name'] ?? '')) ?: 'Secure note';
$payload = shareSeal(['text' => $text], $key);
} elseif ($type === 'Request') {
$related_id = 0;
$name = trim((string) ($in['name'] ?? ''));
if ($name === '') {
$error = 'Say what login you are asking for - it is shown to them, and becomes the credential\'s name.';
return null;
}
// What they send is encrypted with the link's key; the vault keeps a
// copy of that key, so only an agent who can open the vault reads it
$vault_key = encryptCredentialEntry(base64_encode($key));
if ($vault_key === false) {
$error = 'Your vault session has expired - sign out and back in, then try again.';
return null;
}
}
$name = mb_substr($name, 0, 255);
$set = [
"item_active = 1",
"item_version = 2",
"item_key = '" . shareKeyHash($secret) . "'",
"item_type = '$type'",
"item_related_id = $related_id",
"item_name = '" . escapeSql($name) . "'",
"item_encrypted_username = ''",
"item_encrypted_credential = ''",
"item_payload = " . ($payload === null ? 'NULL' : "'" . escapeSql($payload) . "'"),
"item_vault_key = " . ($vault_key === null ? 'NULL' : "'" . escapeSql($vault_key) . "'"),
"item_passphrase = " . ($protect === 'passphrase' ? "'" . escapeSql(password_hash($passphrase, PASSWORD_DEFAULT)) . "'" : 'NULL'),
"item_verify_email = " . ($protect === 'code' ? 1 : 0),
"item_note = '" . escapeSql($note) . "'",
"item_recipient = '" . escapeSql($recipient) . "'",
"item_notify = " . (!empty($in['notify']) ? 1 : 0),
"item_views = 0",
"item_view_limit = $views",
"item_created_by = " . intval($session_user_id),
"item_expire_at = NOW() + INTERVAL $expires HOUR",
"item_client_id = $client_id",
];
mysqli_query($mysqli, "INSERT INTO shared_items SET " . implode(', ', $set));
$item_id = mysqli_insert_id($mysqli);
$url = shareUrl($item_id, $secret);
$row = shareRow($item_id);
$emailed = false;
if ($send) {
$emailed = shareEmailLink($row, $url, $protect);
}
// Logging - on the link, and on the credential, so its history says who it went to
$what = shareTypes()[$type]['label'];
$to = $recipient !== '' ? ' for ' . escapeSql($recipient) : '';
$how = ', ' . strtolower(shareViewOptions()[$views]) . ', for ' . shareExpiryOptions()[$expires]
. ($protect === 'passphrase' ? ', with a passphrase' : '') . ($protect === 'code' ? ', with an emailed code' : '')
. ($emailed ? ', emailed' : '');
if ($type === 'Request') {
logAudit('Share', 'Create', "$session_name asked " . ($recipient !== '' ? escapeSql($recipient) : 'the client') . " for a login - " . escapeSql($name) . " - by link$how", $client_id, $item_id);
} else {
logAudit('Share', 'Create', "$session_name shared $what " . escapeSql($name) . "$to$how", $client_id, $item_id);
}
if ($type === 'Credential') {
logAudit('Credential', 'Share', "$session_name shared credential " . escapeSql($name) . "$to$how", $client_id, $related_id);
}
return [
'item_id' => $item_id,
'url' => $url,
'emailed' => $emailed,
'expire_at' => $row['item_expire_at'],
'passphrase' => $protect === 'passphrase' ? $passphrase : '',
'protect' => $protect,
'recipient' => $recipient,
'views' => $views,
];
}
/** The company sending - its name and how to reach it - for emails and the guest page. */
function shareCompany() {
global $mysqli;
static $company = null;
if ($company === null) {
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT company_name, company_phone, company_phone_country_code, company_email, company_website, company_logo FROM companies WHERE company_id = 1")) ?: [];
$company = [
'name' => (string) ($row['company_name'] ?? ''),
'phone' => !empty($row['company_phone']) ? formatPhoneNumber($row['company_phone'], $row['company_phone_country_code']) : '',
'email' => (string) ($row['company_email'] ?? ''),
'website' => (string) ($row['company_website'] ?? ''),
'logo' => (string) ($row['company_logo'] ?? ''),
];
}
return $company;
}
/** "3:00 PM today", "9:00 AM tomorrow", "Oct 20 at 3:00 PM" - when a link stops working, for the emails. */
function shareExpiryText($expire_at) {
$at = strtotime((string) $expire_at);
if ($at - time() <= 86400) {
return date('g:i A', $at) . (date('Y-m-d', $at) === date('Y-m-d') ? ' today' : ' tomorrow');
}
return date('M j', $at) . ' at ' . date('g:i A', $at);
}
/** Who the shared links' emails come from - the system mailbox, under the company's name when it has none. */
function shareMailFrom() {
$from = emailFrom();
return ['email' => $from['email'], 'name' => $from['name'] !== '' ? $from['name'] : shareCompany()['name']];
}
/**
* Email the link to its recipient, in the email look (functions/email.php).
* Never the passphrase - that goes another way. True when it was queued.
*/
function shareEmailLink($row, $url, $protect) {
global $session_name;
$company = shareCompany();
$agent = stripslashes((string) $session_name);
$from = "$agent at " . $company['name'];
$name = shareName($row);
$expiry = shareExpiryText($row['item_expire_at']);
$limit = intval($row['item_view_limit']);
$intro = [];
if ($row['item_type'] === 'Request') {
$subject = $company['name'] . ' is asking for a login - ' . $row['item_name'];
$email = ['kind' => 'Login request', 'title' => 'Please send us a login', 'preview' => "$from is asking you for $name"];
$intro[] = "$from is asking you for $name. Use the secure link below rather than email - what you type is encrypted and goes straight to us.";
$summary = ['For' => $name, 'Link works' => "Once, until $expiry"];
$button = 'Send it securely';
} else {
$what = ['Credential' => 'login details', 'Note' => 'a secure note', 'Document' => 'a document', 'File' => 'a file'][$row['item_type']] ?? 'something';
$subject = $company['name'] . ' sent you ' . $what . ' - secure link';
$email = ['kind' => 'Secure link', 'title' => $row['item_type'] === 'Credential' ? 'Login details for you' : 'Something to open securely', 'preview' => "$from sent you $what"];
$named = in_array($row['item_type'], ['Credential', 'Document', 'File'], true);
$intro[] = "$from sent you $what" . ($named ? ": $name" : '') . '.';
$summary = ($named ? ['What' => $name] : []) + ['Link works' => "Until $expiry" . ($limit === 1 ? ', once' : ($limit > 1 ? ", $limit times" : ''))];
if ($limit === 1) {
$intro[] = 'It opens only once - copy what you need when you open it.';
}
$button = 'Open it securely';
}
$callout = '';
if ($protect === 'passphrase') {
$callout = "You will need a passphrase to open it - $agent will give it to you another way, such as by phone or text.";
} elseif ($protect === 'code') {
$intro[] = 'To open it, we will email a code to this address.';
}
return (bool) emailQueue([['email' => (string) $row['item_recipient'], 'greet' => '',
'footnote' => 'This email is for the person it was sent to. If it reached you by mistake, delete it and let us know.']], $subject, $email + [
'tone' => 'primary',
'intro' => $intro,
'note' => trim((string) $row['item_note']),
'summary' => $summary,
'callout' => $callout,
'button' => ['label' => $button, 'url' => (string) $url, 'show_url' => true],
], ['from' => shareMailFrom()]);
}
/** The code that opens a link protected by one, to its recipient. True when it was queued. */
function shareEmailCode($row, $code) {
$company = shareCompany();
return (bool) emailQueue([['email' => (string) $row['item_recipient'], 'greet' => '',
'footnote' => 'This email is for the person it was sent to. If it reached you by mistake, delete it and let us know.']], "Your code: $code - " . $company['name'], [
'kind' => 'Secure link', 'tone' => 'primary', 'preview' => "Your code is $code",
'title' => "Your code is $code",
'intro' => ['Enter it on the page that asked for it, to open the secure link ' . $company['name'] . ' sent you. It works for ' . SHARE_CODE_MINUTES . ' minutes.'],
'callout' => 'If you did not ask for it, someone else has the link - ignore this email and let us know.',
], ['from' => shareMailFrom()]);
}
/**
* Tell whoever made the link, in the app - when they asked to be told, or
* always ($always, a link locking). $email sends it by email too (a login a
* client sent back, which is waiting on someone).
*/
function shareNotifyCreator($row, $title, $details, $email = false, $always = false) {
global $mysqli;
if (empty($row['item_notify']) && !$always) {
return;
}
$user_id = intval($row['item_created_by']);
$client_id = intval($row['item_client_id']);
$action = "/agent/shares.php?client_id=$client_id&share=" . intval($row['item_id']);
$user = $user_id ? mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT user_id, user_name, user_email FROM users
WHERE user_id = $user_id AND user_status = 1 AND user_archived_at IS NULL LIMIT 1")) : null;
if (!$user) {
// Made before links had an owner, or the agent has gone - everyone hears
appNotify(escapeSql($title), escapeSql($details), $action, $client_id, intval($row['item_id']));
return;
}
mysqli_query($mysqli, "INSERT INTO notifications SET notification_type = '" . escapeSql(mb_substr($title, 0, 200)) . "', notification = '" . escapeSql(mb_substr($details, 0, 1000)) . "',
notification_action = '" . escapeSql($action) . "', notification_client_id = $client_id, notification_entity_id = " . intval($row['item_id']) . ", notification_user_id = $user_id");
if ($email && shareMailReady()) {
global $config_base_url;
emailQueue([['email' => $user['user_email'], 'name' => $user['user_name'], 'footnote' => 'You get this because you asked for this login with a shared link.']],
"$title - " . $row['client_name'], [
'kind' => 'Shared links', 'tone' => 'primary', 'preview' => $details,
'title' => $title, 'intro' => [$details, "Open it in Shared Links to save it to the client's credentials, or discard it."],
'summary' => ['Client' => (string) $row['client_name'], 'Asked for' => (string) shareName($row)],
'button' => ['label' => 'Review it', 'url' => "https://$config_base_url$action"],
]);
}
}
/**
* Use up one of a link's views - true when this request got it. The UPDATE
* is the claim (claimSharedItemView()), so racing requests cannot both win.
*/
function shareClaim($item_id) {
global $mysqli;
$item_id = intval($item_id);
if (!claimSharedItemView($item_id)) {
return false;
}
mysqli_query($mysqli, "UPDATE shared_items SET item_last_viewed_at = NOW() WHERE item_id = $item_id");
return true;
}
/**
* End a link - turned off, locked, destroyed, saved or discarded - and wipe
* whatever secret it held. True when this call ended it.
*/
function shareEnd($item_id, $reason, $user_id = 0) {
global $mysqli;
$item_id = intval($item_id);
$reason = escapeSql($reason);
$user_id = intval($user_id);
mysqli_query($mysqli, "UPDATE shared_items SET item_active = 0, item_ended_at = NOW(), item_ended_by = $user_id, item_end_reason = '$reason',
item_payload = NULL, item_vault_key = NULL, item_encrypted_username = '', item_encrypted_credential = '', item_code = NULL
WHERE item_id = $item_id AND item_ended_at IS NULL");
return mysqli_affected_rows($mysqli) === 1;
}
/**
* Wipe the secrets links no longer need: once a link ends or expires, and
* SHARE_GRACE_MINUTES after its last view is used. What a client sent back
* goes after SHARE_RECEIVED_KEEP_DAYS unsaved. Cheap - run on every guest
* request, the list page and nightly.
*/
function shareSweep() {
global $mysqli;
$grace = SHARE_GRACE_MINUTES;
mysqli_query($mysqli, "UPDATE shared_items SET item_payload = NULL, item_encrypted_username = '', item_encrypted_credential = '', item_code = NULL
WHERE item_type <> 'Request'
AND (item_payload IS NOT NULL OR item_encrypted_credential <> '' OR item_encrypted_username <> '')
AND (item_ended_at IS NOT NULL OR item_expire_at <= NOW()
OR (item_view_limit > 0 AND item_views >= item_view_limit AND (item_last_viewed_at IS NULL OR item_last_viewed_at < NOW() - INTERVAL $grace MINUTE)))");
mysqli_query($mysqli, "UPDATE shared_items SET item_vault_key = NULL
WHERE item_type = 'Request' AND item_vault_key IS NOT NULL AND item_received_at IS NULL AND (item_ended_at IS NOT NULL OR item_expire_at <= NOW())");
$keep = SHARE_RECEIVED_KEEP_DAYS;
mysqli_query($mysqli, "UPDATE shared_items SET item_active = 0, item_ended_at = NOW(), item_end_reason = 'Discarded', item_payload = NULL, item_vault_key = NULL
WHERE item_type = 'Request' AND item_ended_at IS NULL AND item_received_at < NOW() - INTERVAL $keep DAY");
}
/** Forget links that ended, or expired, more than SHARE_KEEP_DAYS ago - nightly. */
function sharePrune() {
global $mysqli;
$keep = SHARE_KEEP_DAYS;
shareSweep();
mysqli_query($mysqli, "DELETE FROM shared_items
WHERE (item_ended_at IS NOT NULL AND item_ended_at < NOW() - INTERVAL $keep DAY)
OR (item_ended_at IS NULL AND item_received_at IS NULL AND item_expire_at < NOW() - INTERVAL $keep DAY)");
}
/**
* Who did what with a link - made, opened (when, from where, with what),
* wrong passphrases, turned off - from the audit log, oldest first.
*/
function shareHistory($row) {
global $mysqli;
$item_id = intval($row['item_id']);
$client_id = intval($row['item_client_id']);
$since = escapeSql((string) $row['item_created_at']);
$sql = mysqli_query($mysqli, "SELECT log_action, log_description, log_ip, log_user_agent, log_created_at, user_name FROM logs
LEFT JOIN users ON user_id = log_user_id
WHERE log_type = 'Share' AND log_entity_id = $item_id AND log_client_id = $client_id AND log_created_at >= '$since' - INTERVAL 1 MINUTE
ORDER BY log_id ASC LIMIT 200");
$rows = [];
while ($entry = mysqli_fetch_assoc($sql)) {
$rows[] = $entry;
}
return $rows;
}
/** A link's details for the agent - the list and the quick look. */
function shareRows($where, $order = 'item_created_at DESC', $limit = 0, $offset = 0) {
global $mysqli;
$limit_sql = $limit ? 'LIMIT ' . intval($offset) . ', ' . intval($limit) : '';
$sql = mysqli_query($mysqli, "SELECT SQL_CALC_FOUND_ROWS shared_items.item_id, item_active, item_type, item_related_id, item_name,
item_passphrase IS NOT NULL AS item_passphrase, item_verify_email, item_note, item_recipient, item_notify, item_views, item_view_limit,
item_last_viewed_at, item_created_at, item_created_by, item_expire_at, item_received_at, item_ended_at, item_end_reason,
item_failed_attempts, item_client_id, client_name, creator.user_name AS creator_name
FROM shared_items
LEFT JOIN clients ON client_id = item_client_id
LEFT JOIN users creator ON creator.user_id = item_created_by
WHERE $where
ORDER BY $order $limit_sql");
$rows = [];
while ($row = mysqli_fetch_assoc($sql)) {
$rows[] = $row;
}
return $rows;
}
/** What a client sent back to a request, decrypted with the vault - an array, or false when the vault is not open. */
function shareRequestOpen($row) {
if (empty($row['item_payload']) || empty($row['item_vault_key'])) {
return null;
}
$key = decryptCredentialEntry($row['item_vault_key']);
if ($key === false || $key === '') {
return false;
}
$data = shareUnseal($row['item_payload'], base64_decode($key));
return $data === null ? false : $data;
}
/**
* Save what a client sent back as a new credential for the client - the
* request's name, and their URL, username, password, 2FA and note - and
* close the request. The credential's id, or null with $error.
*/
function shareRequestSave($row, &$error = '') {
global $mysqli, $session_name, $session_user_id;
$error = '';
$data = shareRequestOpen($row);
if ($data === false) {
$error = 'Your vault session has expired, so it could not be read - sign out and back in, then try again.';
return null;
}
if (!$data) {
$error = 'There is nothing to save - it may have been saved or discarded already.';
return null;
}
$client_id = intval($row['item_client_id']);
$from = trim((string) $row['item_recipient']);
$note = trim((string) ($data['note'] ?? ''));
$note .= ($note !== '' ? "\n\n" : '') . 'Sent by ' . ($from !== '' ? $from : 'the client') . ' through a request link, ' . date('M j, Y', strtotime((string) $row['item_received_at'])) . '.';
$post = [
'name' => shareName($row),
'uri' => (string) ($data['uri'] ?? ''),
'username' => (string) ($data['username'] ?? ''),
'password' => (string) ($data['password'] ?? ''),
'otp_secret' => (string) ($data['otp'] ?? ''),
'note' => $note,
];
if (credentialOtpSeed($post['otp_secret']) === false) {
// Saved without it rather than not at all - it stays in the note to fix
$post['note'] .= "\n2FA secret as sent (not a valid seed): " . $post['otp_secret'];
$post['otp_secret'] = '';
}
$set = credentialFormSet($post, $client_id, $error);
if ($set === null) {
return null;
}
mysqli_query($mysqli, "INSERT INTO credentials SET $set, credential_password_changed_at = NOW(), credential_client_id = $client_id");
$credential_id = mysqli_insert_id($mysqli);
$item_id = intval($row['item_id']);
shareEnd($item_id, 'Saved', $session_user_id);
mysqli_query($mysqli, "UPDATE shared_items SET item_related_id = $credential_id WHERE item_id = $item_id");
$name = escapeSql($post['name']);
logAudit('Credential', 'Create', "$session_name saved credential $name from a request link" . ($from !== '' ? ' answered by ' . escapeSql($from) : ''), $client_id, $credential_id);
logAudit('Share', 'Save', "$session_name saved what was sent as credential $name", $client_id, $item_id);
return $credential_id;
}
/** The guest's own record of a link in this browser - verified (passphrase or code) and opened, as times. */
function shareSession($item_id) {
return $_SESSION['itflow_shares'][intval($item_id)] ?? ['verified' => 0, 'opened' => 0];
}
function shareSessionMark($item_id, $what) {
$item_id = intval($item_id);
$state = shareSession($item_id);
$state[$what] = time();
$_SESSION['itflow_shares'][$item_id] = $state;
}
/** Has this browser passed the link's passphrase or code (recently)? Always for a link without one. */
function shareVerified($row) {
if (shareProtection($row) === '') {
return true;
}
return shareSession($row['item_id'])['verified'] > time() - SHARE_GRACE_MINUTES * 60;
}
/** Did this browser open the link recently - may it open it again without using a view? */
function shareOpenedHere($row) {
return shareSession($row['item_id'])['opened'] > time() - SHARE_GRACE_MINUTES * 60;
}
/**
* Take one passphrase or code try - false when the link is out of tries.
* The UPDATE is the claim, so a burst of guesses cannot get past the limit.
*/
function shareTakeAttempt($item_id) {
global $mysqli;
$item_id = intval($item_id);
$attempts = SHARE_ATTEMPTS;
mysqli_query($mysqli, "UPDATE shared_items SET item_failed_attempts = item_failed_attempts + 1
WHERE item_id = $item_id AND item_ended_at IS NULL AND item_failed_attempts < $attempts");
return mysqli_affected_rows($mysqli) === 1;
}
/** A menu item that opens what a link shares (or the credential a request was saved as) - '' when it is gone or not this agent's to see. */
function shareItemMenuLink($row) {
$id = intval($row['item_related_id']);
if (!$id) {
return '';
}
$type = $row['item_type'];
if (in_array($type, ['Credential', 'Request'], true)) {
if (lookupUserPermission('module_credential') < 1 || ($type === 'Request' && ($row['item_end_reason'] ?? '') !== 'Saved')) {
return '';
}
$live = shareItemLive(['item_type' => 'Credential', 'item_related_id' => $id, 'item_client_id' => $row['item_client_id']]);
return $live === null ? '' : '<a class="dropdown-item ajax-modal" href="#" data-modal-url="modals/credential/credential.php?id=' . $id . '"><i class="fas fa-fw fa-key text-secondary me-2"></i>' . ($type === 'Request' ? 'Open the saved credential' : 'Open the credential') . '</a>';
}
if (shareItemLive($row) === null) {
return '';
}
if ($type === 'Document') {
return '<a class="dropdown-item" href="document.php?client_id=' . intval($row['item_client_id']) . '&amp;document_id=' . $id . '"><i class="fas fa-fw fa-file-alt text-secondary me-2"></i>Open the document</a>';
}
if ($type === 'File') {
return '<a class="dropdown-item ajax-modal" href="#" data-modal-size="lg" data-modal-url="modals/file/file.php?id=' . $id . '"><i class="fas fa-fw fa-paperclip text-secondary me-2"></i>Open the file</a>';
}
return '';
}