mirror of
https://github.com/HeyPuter/puter
synced 2024-11-14 14:03:42 +00:00
refactor: share endpoint
This commit is contained in:
parent
5551326c98
commit
b78c83a4ab
@ -51,3 +51,9 @@ Comments beginning with `// track:`. See
|
||||
some object that's in scope. This could be either to pass
|
||||
onto the browser or to pass to something like the
|
||||
notification service.
|
||||
- `track: common operations on multiple items`
|
||||
A patterm which emerges when multiple variables have
|
||||
common operations done upon them in sequence.
|
||||
It may be applicable to write an iterator in the
|
||||
future, or something will come up that require
|
||||
these to be handled with a modular approach instead.
|
||||
|
@ -109,7 +109,7 @@ class Sequence {
|
||||
async run (values) {
|
||||
// Initialize scope
|
||||
values = values || this.thisArg?.values || {};
|
||||
Object.assign(this.scope_, values); // TODO: can this be __proto__?
|
||||
this.scope_.__proto__ = values;
|
||||
|
||||
// Run sequence
|
||||
for ( ; this.i < this.steps.length ; this.i++ ) {
|
||||
@ -138,7 +138,14 @@ class Sequence {
|
||||
await this.sequence_.options_.before_each(this, step);
|
||||
}
|
||||
|
||||
this.last_return_ = await step.fn(this);
|
||||
this.last_return_ = await step.fn.call(
|
||||
this.thisArg, this,
|
||||
);
|
||||
|
||||
if ( this.sequence_.options_.after_each ) {
|
||||
await this.sequence_.options_.after_each(this, step);
|
||||
}
|
||||
|
||||
if ( this.stopped_ ) {
|
||||
break;
|
||||
}
|
||||
@ -186,7 +193,13 @@ class Sequence {
|
||||
this.scope_[k] = v;
|
||||
}
|
||||
|
||||
values () {
|
||||
values (opt_itemsToSet) {
|
||||
if ( opt_itemsToSet ) {
|
||||
for ( const k in opt_itemsToSet ) {
|
||||
this.set(k, opt_itemsToSet[k]);
|
||||
}
|
||||
}
|
||||
|
||||
return new Proxy(this.scope_, {
|
||||
get: (target, property) => {
|
||||
if (property in target) {
|
||||
@ -231,16 +244,21 @@ class Sequence {
|
||||
}
|
||||
}
|
||||
|
||||
const fn = async function () {
|
||||
const fn = async function (opt_values) {
|
||||
if ( opt_values && opt_values instanceof Sequence.SequenceState ) {
|
||||
opt_values = opt_values.scope_;
|
||||
}
|
||||
const state = new Sequence.SequenceState(sequence, this);
|
||||
await state.run();
|
||||
await state.run(opt_values ?? undefined);
|
||||
return state.last_return_;
|
||||
}
|
||||
|
||||
this.steps_ = steps;
|
||||
this.options_ = options || {};
|
||||
|
||||
Object.defineProperty(fn, 'name', { value: 'Sequence' });
|
||||
Object.defineProperty(fn, 'name', {
|
||||
value: options.name || 'Sequence'
|
||||
});
|
||||
Object.defineProperty(fn, 'sequence', { value: this });
|
||||
|
||||
return fn;
|
||||
|
@ -1,636 +0,0 @@
|
||||
const express = require('express');
|
||||
const { Endpoint } = require('../util/expressutil');
|
||||
|
||||
const validator = require('validator');
|
||||
const APIError = require('../api/APIError');
|
||||
const { get_user, get_app } = require('../helpers');
|
||||
const { Context } = require('../util/context');
|
||||
const config = require('../config');
|
||||
const FSNodeParam = require('../api/filesystem/FSNodeParam');
|
||||
const { TYPE_DIRECTORY } = require('../filesystem/FSNodeContext');
|
||||
|
||||
const { PermissionUtil } = require('../services/auth/PermissionService');
|
||||
const configurable_auth = require('../middleware/configurable_auth');
|
||||
const { UsernameNotifSelector } = require('../services/NotificationService');
|
||||
const { quot } = require('../util/strutil');
|
||||
const { UtilFn } = require('../util/fnutil');
|
||||
const { WorkList } = require('../util/workutil');
|
||||
const { DB_WRITE } = require('../services/database/consts');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const v0_2 = async (req, res) => {
|
||||
const svc_token = req.services.get('token');
|
||||
const svc_email = req.services.get('email');
|
||||
const svc_permission = req.services.get('permission');
|
||||
const svc_notification = req.services.get('notification');
|
||||
const svc_share = req.services.get('share');
|
||||
|
||||
const lib_typeTagged = req.services.get('lib-type-tagged');
|
||||
|
||||
const actor = Context.get('actor');
|
||||
|
||||
const db = req.services.get('database').get('share', DB_WRITE);
|
||||
|
||||
// === Request Validators ===
|
||||
|
||||
const validate_mode = UtilFn(mode => {
|
||||
if ( mode === 'strict' ) return true;
|
||||
if ( ! mode || mode === 'best-effort' ) return false;
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'mode',
|
||||
expected: '`strict`, `best-effort`, or undefined',
|
||||
});
|
||||
})
|
||||
|
||||
// Expect: an array of usernames and/or emails
|
||||
const validate_recipients = UtilFn(recipients => {
|
||||
// A string can be adapted to an array of one string
|
||||
if ( typeof recipients === 'string' ) {
|
||||
recipients = [recipients];
|
||||
}
|
||||
// Must be an array
|
||||
if ( ! Array.isArray(recipients) ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'recipients',
|
||||
expected: 'array or string',
|
||||
got: typeof recipients,
|
||||
})
|
||||
}
|
||||
return recipients;
|
||||
});
|
||||
|
||||
const validate_shares = UtilFn(shares => {
|
||||
// Single-values get adapted into an array
|
||||
if ( ! Array.isArray(shares) ) {
|
||||
shares = [shares];
|
||||
}
|
||||
return shares;
|
||||
})
|
||||
|
||||
// === Request Values ===
|
||||
|
||||
const strict_mode =
|
||||
validate_mode.if(req.body.mode) ?? false;
|
||||
const req_recipients =
|
||||
validate_recipients.if(req.body.recipients) ?? [];
|
||||
const req_shares =
|
||||
validate_shares.if(req.body.shares) ?? [];
|
||||
|
||||
// === State Values ===
|
||||
|
||||
const recipients = [];
|
||||
const result = {
|
||||
// Metadata
|
||||
$: 'api:share',
|
||||
$version: 'v0.0.0',
|
||||
|
||||
// Results
|
||||
status: null,
|
||||
recipients: Array(req_recipients.length).fill(null),
|
||||
shares: Array(req_shares.length).fill(null),
|
||||
}
|
||||
const recipients_work = new WorkList();
|
||||
const shares_work = new WorkList();
|
||||
|
||||
// const assert_work_item = (wut, item) => {
|
||||
// if ( item.$ !== wut ) {
|
||||
// // This should never happen, so 500 is acceptable here
|
||||
// throw new Error('work item assertion failed');
|
||||
// }
|
||||
// }
|
||||
|
||||
// === Request Preprocessing ===
|
||||
|
||||
// --- Function that returns early in strict mode ---
|
||||
const serialize_result = () => {
|
||||
for ( let i=0 ; i < result.recipients.length ; i++ ) {
|
||||
if ( ! result.recipients[i] ) continue;
|
||||
if ( result.recipients[i] instanceof APIError ) {
|
||||
result.status = 'mixed';
|
||||
result.recipients[i] = result.recipients[i].serialize();
|
||||
}
|
||||
}
|
||||
for ( let i=0 ; i < result.shares.length ; i++ ) {
|
||||
if ( ! result.shares[i] ) continue;
|
||||
if ( result.shares[i] instanceof APIError ) {
|
||||
result.status = 'mixed';
|
||||
result.shares[i] = result.shares[i].serialize();
|
||||
}
|
||||
}
|
||||
};
|
||||
const strict_check = () =>{
|
||||
if ( ! strict_mode ) return;
|
||||
console.log('OK');
|
||||
if (
|
||||
result.recipients.some(v => v !== null) ||
|
||||
result.shares.some(v => v !== null)
|
||||
) {
|
||||
console.log('DOESNT THIS??')
|
||||
serialize_result();
|
||||
result.status = 'aborted';
|
||||
res.status(218).send(result);
|
||||
console.log('HOWW???');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Process Recipients ---
|
||||
|
||||
// Expect: at least one recipient
|
||||
if ( req_recipients.length < 1 ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'recipients',
|
||||
expected: 'at least one',
|
||||
got: 'none',
|
||||
})
|
||||
}
|
||||
|
||||
for ( let i=0 ; i < req_recipients.length ; i++ ) {
|
||||
const value = req_recipients[i];
|
||||
recipients_work.push({ i, value })
|
||||
}
|
||||
recipients_work.lockin();
|
||||
|
||||
// track: good candidate for sequence
|
||||
|
||||
// Expect: each value should be a valid username or email
|
||||
for ( const item of recipients_work.list() ) {
|
||||
const { value, i } = item;
|
||||
|
||||
if ( typeof value !== 'string' ) {
|
||||
item.invalid = true;
|
||||
result.recipients[i] =
|
||||
APIError.create('invalid_username_or_email', null, {
|
||||
value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( value.match(config.username_regex) ) {
|
||||
item.type = 'username';
|
||||
continue;
|
||||
}
|
||||
if ( validator.isEmail(value) ) {
|
||||
item.type = 'email';
|
||||
continue;
|
||||
}
|
||||
|
||||
item.invalid = true;
|
||||
result.recipients[i] =
|
||||
APIError.create('invalid_username_or_email', null, {
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
// Return: if there are invalid values in strict mode
|
||||
recipients_work.clear_invalid();
|
||||
|
||||
// Expect: no emails specified yet
|
||||
// AND usernames exist
|
||||
for ( const item of recipients_work.list() ) {
|
||||
const allowed_types = ['email', 'username'];
|
||||
if ( ! allowed_types.includes(item.type) ) {
|
||||
item.invalid = true;
|
||||
result.recipients[item.i] =
|
||||
APIError.create('disallowed_value', null, {
|
||||
key: `recipients[${item.i}].type`,
|
||||
allowed: allowed_types,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Return: if there are invalid values in strict mode
|
||||
recipients_work.clear_invalid();
|
||||
|
||||
for ( const item of recipients_work.list() ) {
|
||||
if ( item.type !== 'email' ) continue;
|
||||
|
||||
const errors = [];
|
||||
if ( ! validator.isEmail(item.value) ) {
|
||||
errors.push('`email` is not valid');
|
||||
}
|
||||
|
||||
if ( errors.length ) {
|
||||
item.invalid = true;
|
||||
result.recipients[item.i] =
|
||||
APIError.create('field_errors', null, {
|
||||
key: `recipients[${item.i}]`,
|
||||
errors,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
recipients_work.clear_invalid();
|
||||
|
||||
// CHECK EXISTING USERS FOR EMAIL SHARES
|
||||
for ( const recipient_item of recipients_work.list() ) {
|
||||
if ( recipient_item.type !== 'email' ) continue;
|
||||
const user = await get_user({
|
||||
email: recipient_item.value,
|
||||
});
|
||||
if ( ! user ) continue;
|
||||
recipient_item.type = 'username';
|
||||
recipient_item.value = user.username;
|
||||
}
|
||||
|
||||
recipients_work.clear_invalid();
|
||||
|
||||
// Check: users specified by username exist
|
||||
for ( const item of recipients_work.list() ) {
|
||||
if ( item.type !== 'username' ) continue;
|
||||
|
||||
const user = await get_user({ username: item.value });
|
||||
if ( ! user ) {
|
||||
item.invalid = true;
|
||||
result.recipients[item.i] =
|
||||
APIError.create('user_does_not_exist', null, {
|
||||
username: item.value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
item.user = user;
|
||||
}
|
||||
|
||||
// Return: if there are invalid values in strict mode
|
||||
recipients_work.clear_invalid();
|
||||
|
||||
// --- Process Paths ---
|
||||
|
||||
// Expect: at least one path
|
||||
if ( req_shares.length < 1 ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'shares',
|
||||
expected: 'at least one',
|
||||
got: 'none',
|
||||
})
|
||||
}
|
||||
|
||||
for ( let i=0 ; i < req_shares.length ; i++ ) {
|
||||
const value = req_shares[i];
|
||||
shares_work.push({ i, value });
|
||||
}
|
||||
shares_work.lockin();
|
||||
|
||||
// Check: all share items are a type-tagged-object
|
||||
// with one of these types: fs-share, app-share.
|
||||
for ( const item of shares_work.list() ) {
|
||||
const { i } = item;
|
||||
let { value } = item;
|
||||
|
||||
const thing = lib_typeTagged.process(value);
|
||||
if ( thing.$ === 'error' ) {
|
||||
item.invalid = true;
|
||||
result.shares[i] =
|
||||
APIError.create('format_error', null, {
|
||||
message: thing.message
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const allowed_things = ['fs-share', 'app-share'];
|
||||
if ( ! allowed_things.includes(thing.$) ) {
|
||||
item.invalid = true;
|
||||
result.shares[i] =
|
||||
APIError.create('disallowed_thing', null, {
|
||||
thing: thing.$,
|
||||
accepted: allowed_things,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
item.thing = thing;
|
||||
}
|
||||
|
||||
shares_work.clear_invalid();
|
||||
|
||||
// Process: create $share-intent:file for file items
|
||||
for ( const item of shares_work.list() ) {
|
||||
const { thing } = item;
|
||||
if ( thing.$ !== 'fs-share' ) continue;
|
||||
|
||||
item.type = 'fs';
|
||||
const errors = [];
|
||||
if ( ! thing.path ) {
|
||||
errors.push('`path` is required');
|
||||
}
|
||||
let access = thing.access;
|
||||
if ( access ) {
|
||||
if ( ! ['read','write'].includes(access) ) {
|
||||
errors.push('`access` should be `read` or `write`');
|
||||
}
|
||||
} else access = 'read';
|
||||
|
||||
if ( errors.length ) {
|
||||
item.invalid = true;
|
||||
result.shares[item.i] =
|
||||
APIError.create('field_errors', null, {
|
||||
key: `shares[${item.i}]`,
|
||||
errors
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
item.path = thing.path;
|
||||
item.share_intent = {
|
||||
$: 'share-intent:file',
|
||||
permissions: [PermissionUtil.join('fs', thing.path, access)],
|
||||
};
|
||||
}
|
||||
|
||||
shares_work.clear_invalid();
|
||||
|
||||
// Process: create $share-intent:app for app items
|
||||
for ( const item of shares_work.list() ) {
|
||||
const { thing } = item;
|
||||
if ( thing.$ !== 'app-share' ) continue;
|
||||
|
||||
item.type = 'app';
|
||||
const errors = [];
|
||||
if ( ! thing.uid && ! thing.name ) {
|
||||
errors.push('`uid` or `name` is required');
|
||||
}
|
||||
|
||||
if ( errors.length ) {
|
||||
item.invalid = true;
|
||||
result.shares[item.i] =
|
||||
APIError.create('field_errors', null, {
|
||||
key: `shares[${item.i}]`,
|
||||
errors
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const app_selector = thing.uid
|
||||
? `uid#${thing.uid}` : thing.name;
|
||||
|
||||
item.share_intent = {
|
||||
$: 'share-intent:app',
|
||||
permissions: [
|
||||
PermissionUtil.join('app', app_selector, 'access')
|
||||
]
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
shares_work.clear_invalid();
|
||||
|
||||
for ( const item of shares_work.list() ) {
|
||||
if ( item.type !== 'fs' ) continue;
|
||||
const node = await (new FSNodeParam('path')).consolidate({
|
||||
req, getParam: () => item.path
|
||||
});
|
||||
|
||||
if ( ! await node.exists() ) {
|
||||
item.invalid = true;
|
||||
result.shares[item.i] = APIError.create('subject_does_not_exist', {
|
||||
path: item.path,
|
||||
})
|
||||
continue;
|
||||
}
|
||||
|
||||
item.node = node;
|
||||
let email_path = item.path;
|
||||
let is_dir = true;
|
||||
if ( await node.get('type') !== TYPE_DIRECTORY ) {
|
||||
is_dir = false;
|
||||
// remove last component
|
||||
email_path = email_path.slice(0, item.path.lastIndexOf('/')+1);
|
||||
}
|
||||
|
||||
if ( email_path.startsWith('/') ) email_path = email_path.slice(1);
|
||||
const email_link = `${config.origin}/show/${email_path}`;
|
||||
item.is_dir = is_dir;
|
||||
item.email_link = email_link;
|
||||
}
|
||||
|
||||
shares_work.clear_invalid();
|
||||
|
||||
// Fetch app info for app shares
|
||||
for ( const item of shares_work.list() ) {
|
||||
if ( item.type !== 'app' ) continue;
|
||||
const { thing } = item;
|
||||
|
||||
const app = await get_app(thing.uid ?
|
||||
{ uid: thing.uid } : { name: thing.name });
|
||||
if ( ! app ) {
|
||||
item.invalid = true;
|
||||
result.shares[item.i] =
|
||||
// note: since we're reporting `entity_not_found`
|
||||
// we will report the id as an entity-storage-compatible
|
||||
// identifier.
|
||||
APIError.create('entity_not_found', null, {
|
||||
identifier: thing.uid
|
||||
? { uid: thing.uid }
|
||||
: { id: { name: thing.name } }
|
||||
});
|
||||
}
|
||||
|
||||
app.metadata = db.case({
|
||||
mysql: () => app.metadata,
|
||||
otherwise: () => JSON.parse(app.metadata ?? '{}')
|
||||
})();
|
||||
|
||||
item.app = app;
|
||||
}
|
||||
|
||||
shares_work.clear_invalid();
|
||||
|
||||
// Process: conditionally add permission for subdomain
|
||||
for ( const item of shares_work.list() ) {
|
||||
if ( item.type !== 'app' ) continue;
|
||||
const [subdomain] = await db.read(
|
||||
`SELECT * FROM subdomains WHERE associated_app_id = ? ` +
|
||||
`AND user_id = ? LIMIT 1`,
|
||||
[item.app.id, actor.type.user.id]
|
||||
);
|
||||
if ( ! subdomain ) continue;
|
||||
|
||||
// The subdomain is also owned by this user, so we'll
|
||||
// add a permission for that as well
|
||||
|
||||
const site_selector = `uid#${subdomain.uuid}`;
|
||||
item.share_intent.permissions.push(
|
||||
PermissionUtil.join('site', site_selector, 'access')
|
||||
)
|
||||
}
|
||||
|
||||
// Process: conditionally add permission for AppData
|
||||
for ( const item of shares_work.list() ) {
|
||||
if ( item.type !== 'app' ) continue;
|
||||
if ( ! item.app.metadata?.shared_appdata ) continue;
|
||||
|
||||
const app_owner = await get_user({ id: item.app.owner_user_id });
|
||||
|
||||
const appdatadir =
|
||||
`/${app_owner.username}/AppData/${item.app.uid}`;
|
||||
const appdatadir_perm =
|
||||
PermissionUtil.join('fs', appdatadir, 'write');
|
||||
|
||||
item.share_intent.permissions.push(appdatadir_perm);
|
||||
}
|
||||
|
||||
shares_work.clear_invalid();
|
||||
|
||||
// Mark files as successful; further errors will be
|
||||
// reported on recipients instead.
|
||||
for ( const item of shares_work.list() ) {
|
||||
result.shares[item.i] =
|
||||
{
|
||||
$: 'api:status-report',
|
||||
status: 'success',
|
||||
fields: {
|
||||
permission: item.permission,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if ( strict_check() ) return;
|
||||
if ( req.body.dry_run ) {
|
||||
// Mark everything left as successful
|
||||
for ( const item of recipients_work.list() ) {
|
||||
result.recipients[item.i] =
|
||||
{ $: 'api:status-report', status: 'success' };
|
||||
}
|
||||
|
||||
result.status = 'success';
|
||||
result.dry_run = true;
|
||||
serialize_result();
|
||||
res.send(result);
|
||||
return;
|
||||
}
|
||||
|
||||
for ( const recipient_item of recipients_work.list() ) {
|
||||
if ( recipient_item.type !== 'username' ) continue;
|
||||
|
||||
const username = recipient_item.user.username;
|
||||
|
||||
for ( const share_item of shares_work.list() ) {
|
||||
const permissions = share_item.share_intent.permissions;
|
||||
for ( const perm of permissions ) {
|
||||
await svc_permission.grant_user_user_permission(
|
||||
actor,
|
||||
username,
|
||||
perm,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Need to re-work this for multiple files
|
||||
/*
|
||||
const email_values = {
|
||||
link: recipient_item.email_link,
|
||||
susername: req.user.username,
|
||||
rusername: username,
|
||||
};
|
||||
|
||||
const email_tmpl = 'share_existing_user';
|
||||
|
||||
await svc_email.send_email(
|
||||
{ email: recipient_item.user.email },
|
||||
email_tmpl,
|
||||
email_values,
|
||||
);
|
||||
*/
|
||||
|
||||
const files = []; {
|
||||
for ( const item of shares_work.list() ) {
|
||||
if ( item.type !== 'file' ) continue;
|
||||
files.push(
|
||||
await item.node.getSafeEntry(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const apps = []; {
|
||||
for ( const item of shares_work.list() ) {
|
||||
if ( item.type !== 'app' ) continue;
|
||||
// TODO: is there a general way to create a
|
||||
// client-safe app right now without
|
||||
// going through entity storage?
|
||||
// track: manual safe object
|
||||
apps.push(item.name
|
||||
? item.name : await get_app({
|
||||
uid: item.uid,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
svc_notification.notify(UsernameNotifSelector(username), {
|
||||
source: 'sharing',
|
||||
icon: 'shared.svg',
|
||||
title: 'Files were shared with you!',
|
||||
template: 'file-shared-with-you',
|
||||
fields: {
|
||||
username: actor.type.user.username,
|
||||
files,
|
||||
},
|
||||
text: `The user ${quot(req.user.username)} shared ` +
|
||||
`${files.length} ` +
|
||||
(files.length === 1 ? 'file' : 'files') + ' ' +
|
||||
'with you.',
|
||||
});
|
||||
|
||||
result.recipients[recipient_item.i] =
|
||||
{ $: 'api:status-report', status: 'success' };
|
||||
}
|
||||
|
||||
for ( const recipient_item of recipients_work.list() ) {
|
||||
if ( recipient_item.type !== 'email' ) continue;
|
||||
|
||||
const email = recipient_item.value;
|
||||
|
||||
// data that gets stored in the `data` column of the share
|
||||
const data = {
|
||||
$: 'internal:share',
|
||||
$v: 'v0.0.0',
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
for ( const share_item of shares_work.list() ) {
|
||||
const permissions = share_item.share_intent.permissions;
|
||||
data.permissions.push(...permissions);
|
||||
}
|
||||
|
||||
// track: scoping iife
|
||||
const share_token = await (async () => {
|
||||
const share_uid = await svc_share.create_share({
|
||||
issuer: actor,
|
||||
email,
|
||||
data,
|
||||
});
|
||||
return svc_token.sign('share', {
|
||||
$: 'token:share',
|
||||
$v: '0.0.0',
|
||||
uid: share_uid,
|
||||
}, {
|
||||
expiresIn: '14d'
|
||||
});
|
||||
})();
|
||||
|
||||
const email_link =
|
||||
`${config.origin}?share_token=${share_token}`;
|
||||
|
||||
await svc_email.send_email({ email }, 'share_by_email', {
|
||||
link: email_link,
|
||||
});
|
||||
}
|
||||
|
||||
result.status = 'success';
|
||||
serialize_result(); // might change result.status to 'mixed'
|
||||
res.send(result);
|
||||
};
|
||||
|
||||
Endpoint({
|
||||
// "item" here means a filesystem node
|
||||
route: '/',
|
||||
mw: [configurable_auth()],
|
||||
methods: ['POST'],
|
||||
handler: v0_2,
|
||||
}).attach(router);
|
||||
|
||||
module.exports = app => {
|
||||
app.use('/share', router);
|
||||
};
|
@ -76,7 +76,6 @@ class PuterAPIService extends BaseService {
|
||||
app.use(require('../routers/healthcheck'))
|
||||
app.use(require('../routers/test'))
|
||||
app.use(require('../routers/update-taskbar-items'))
|
||||
require('../routers/share')(app);
|
||||
require('../routers/whoami')(app);
|
||||
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
const APIError = require("../api/APIError");
|
||||
const { get_user } = require("../helpers");
|
||||
const configurable_auth = require("../middleware/configurable_auth");
|
||||
const { Context } = require("../util/context");
|
||||
const { Endpoint } = require("../util/expressutil");
|
||||
const { whatis } = require("../util/langutil");
|
||||
const { Actor, UserActorType } = require("./auth/Actor");
|
||||
@ -20,6 +21,11 @@ class ShareService extends BaseService {
|
||||
}
|
||||
|
||||
['__on_install.routes'] (_, { app }) {
|
||||
this.install_sharelink_endpoints({ app });
|
||||
this.install_share_endpoint({ app });
|
||||
}
|
||||
|
||||
install_sharelink_endpoints ({ app }) {
|
||||
// track: scoping iife
|
||||
const router = (() => {
|
||||
const require = this.require;
|
||||
@ -208,6 +214,30 @@ class ShareService extends BaseService {
|
||||
}).attach(router);
|
||||
}
|
||||
|
||||
install_share_endpoint ({ app }) {
|
||||
// track: scoping iife
|
||||
const router = (() => {
|
||||
const require = this.require;
|
||||
const express = require('express');
|
||||
return express.Router();
|
||||
})();
|
||||
|
||||
app.use('/share', router);
|
||||
|
||||
const share_sequence = require('../structured/sequence/share.js');
|
||||
Endpoint({
|
||||
route: '/',
|
||||
methods: ['POST'],
|
||||
mw: [configurable_auth()],
|
||||
handler: async (req, res) => {
|
||||
const actor = Actor.adapt(req.user);
|
||||
return await share_sequence.call(this, {
|
||||
actor, req, res,
|
||||
});
|
||||
}
|
||||
}).attach(router);
|
||||
}
|
||||
|
||||
async get_share ({ uid }) {
|
||||
const [share] = await this.db.read(
|
||||
'SELECT * FROM share WHERE uid = ?',
|
||||
|
6
packages/backend/src/structured/README.md
Normal file
6
packages/backend/src/structured/README.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Structured Code
|
||||
|
||||
Each directory in this directory represents some type of
|
||||
structured code. For example, everything in the directory
|
||||
`./sequence` (relative to this file's location) is a
|
||||
cjs module that exports an instance of [Sequence](../codex/Sequence.js).
|
584
packages/backend/src/structured/sequence/share.js
Normal file
584
packages/backend/src/structured/sequence/share.js
Normal file
@ -0,0 +1,584 @@
|
||||
const APIError = require("../../api/APIError");
|
||||
const { Sequence } = require("../../codex/Sequence");
|
||||
const config = require("../../config");
|
||||
const { WorkList } = require("../../util/workutil");
|
||||
|
||||
const validator = require('validator');
|
||||
const { get_user, get_app } = require("../../helpers");
|
||||
const { PermissionUtil } = require("../../services/auth/PermissionService");
|
||||
const FSNodeParam = require("../../api/filesystem/FSNodeParam");
|
||||
const { TYPE_DIRECTORY } = require("../../filesystem/FSNodeContext");
|
||||
const { UsernameNotifSelector } = require("../../services/NotificationService");
|
||||
const { quot } = require("../../util/strutil");
|
||||
|
||||
/*
|
||||
This code is optimized for editors supporting folding.
|
||||
Fold at Level 2 to conveniently browse sequence steps.
|
||||
Fold at Level 3 after opening an inner-sequence.
|
||||
|
||||
If you're using VSCode {
|
||||
typically "Ctrl+K, Ctrl+2" or "⌘K, ⌘2";
|
||||
to revert "Ctrl+K, Ctrl+J" or "⌘K, ⌘J";
|
||||
https://stackoverflow.com/questions/30067767
|
||||
}
|
||||
*/
|
||||
|
||||
|
||||
module.exports = new Sequence([
|
||||
function validate_mode (a) {
|
||||
const req = a.get('req');
|
||||
const mode = req.body.mode;
|
||||
|
||||
if ( mode === 'strict' ) {
|
||||
a.set('strict_mode', true);
|
||||
return;
|
||||
}
|
||||
if ( ! mode || mode === 'best-effort' ) {
|
||||
a.set('strict_mode', false);
|
||||
return;
|
||||
}
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'mode',
|
||||
expected: '`strict`, `best-effort`, or undefined',
|
||||
});
|
||||
},
|
||||
function validate_recipients (a) {
|
||||
const req = a.get('req');
|
||||
let recipients = req.body.recipients;
|
||||
|
||||
// A string can be adapted to an array of one string
|
||||
if ( typeof recipients === 'string' ) {
|
||||
recipients = [recipients];
|
||||
}
|
||||
// Must be an array
|
||||
if ( ! Array.isArray(recipients) ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'recipients',
|
||||
expected: 'array or string',
|
||||
got: typeof recipients,
|
||||
})
|
||||
}
|
||||
// At least one recipient
|
||||
if ( recipients.length < 1 ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'recipients',
|
||||
expected: 'at least one',
|
||||
got: 'none',
|
||||
});
|
||||
}
|
||||
a.set('req_recipients', recipients);
|
||||
},
|
||||
function validate_shares (a) {
|
||||
const req = a.get('req');
|
||||
let shares = req.body.shares;
|
||||
|
||||
if ( ! Array.isArray(shares) ) {
|
||||
shares = [shares];
|
||||
}
|
||||
|
||||
// At least one share
|
||||
if ( shares.length < 1 ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'shares',
|
||||
expected: 'at least one',
|
||||
got: 'none',
|
||||
});
|
||||
}
|
||||
|
||||
a.set('req_shares', shares);
|
||||
},
|
||||
function initialize_result_object (a) {
|
||||
a.set('result', {
|
||||
$: 'api:share',
|
||||
$version: 'v0.0.0',
|
||||
status: null,
|
||||
recipients:
|
||||
Array(a.get('req_recipients').length).fill(null),
|
||||
shares:
|
||||
Array(a.get('req_shares').length).fill(null),
|
||||
serialize () {
|
||||
const result = this;
|
||||
for ( let i=0 ; i < result.recipients.length ; i++ ) {
|
||||
if ( ! result.recipients[i] ) continue;
|
||||
if ( result.recipients[i] instanceof APIError ) {
|
||||
result.status = 'mixed';
|
||||
result.recipients[i] = result.recipients[i].serialize();
|
||||
}
|
||||
}
|
||||
for ( let i=0 ; i < result.shares.length ; i++ ) {
|
||||
if ( ! result.shares[i] ) continue;
|
||||
if ( result.shares[i] instanceof APIError ) {
|
||||
result.status = 'mixed';
|
||||
result.shares[i] = result.shares[i].serialize();
|
||||
}
|
||||
}
|
||||
delete result.serialize;
|
||||
return result;
|
||||
}
|
||||
});
|
||||
},
|
||||
function initialize_worklists (a) {
|
||||
const recipients_work = new WorkList();
|
||||
const shares_work = new WorkList();
|
||||
|
||||
const { req_recipients, req_shares } = a.values();
|
||||
|
||||
// track: common operations on multiple items
|
||||
|
||||
for ( let i=0 ; i < req_recipients.length ; i++ ) {
|
||||
const value = req_recipients[i];
|
||||
recipients_work.push({ i, value });
|
||||
}
|
||||
|
||||
for ( let i=0 ; i < req_shares.length ; i++ ) {
|
||||
const value = req_shares[i];
|
||||
shares_work.push({ i, value });
|
||||
}
|
||||
|
||||
recipients_work.lockin();
|
||||
shares_work.lockin();
|
||||
|
||||
a.values({ recipients_work, shares_work });
|
||||
},
|
||||
new Sequence({ name: 'process recipients',
|
||||
after_each (a) {
|
||||
const { recipients_work } = a.values();
|
||||
recipients_work.clear_invalid();
|
||||
}
|
||||
}, [
|
||||
function valid_username_or_email (a) {
|
||||
const { result, recipients_work } = a.values();
|
||||
for ( const item of recipients_work.list() ) {
|
||||
const { value, i } = item;
|
||||
|
||||
if ( typeof value !== 'string' ) {
|
||||
item.invalid = true;
|
||||
result.recipients[i] =
|
||||
APIError.create('invalid_username_or_email', null, {
|
||||
value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( value.match(config.username_regex) ) {
|
||||
item.type = 'username';
|
||||
continue;
|
||||
}
|
||||
if ( validator.isEmail(value) ) {
|
||||
item.type = 'email';
|
||||
continue;
|
||||
}
|
||||
|
||||
item.invalid = true;
|
||||
result.recipients[i] =
|
||||
APIError.create('invalid_username_or_email', null, {
|
||||
value,
|
||||
});
|
||||
}
|
||||
},
|
||||
async function check_existing_users_for_email_shares (a) {
|
||||
const { recipients_work } = a.values();
|
||||
for ( const recipient_item of recipients_work.list() ) {
|
||||
if ( recipient_item.type !== 'email' ) continue;
|
||||
const user = await get_user({
|
||||
email: recipient_item.value,
|
||||
});
|
||||
if ( ! user ) continue;
|
||||
recipient_item.type = 'username';
|
||||
recipient_item.value = user.username;
|
||||
}
|
||||
},
|
||||
async function check_username_specified_users_exist (a) {
|
||||
const { result, recipients_work } = a.values();
|
||||
for ( const item of recipients_work.list() ) {
|
||||
if ( item.type !== 'username' ) continue;
|
||||
|
||||
const user = await get_user({ username: item.value });
|
||||
if ( ! user ) {
|
||||
item.invalid = true;
|
||||
result.recipients[item.i] =
|
||||
APIError.create('user_does_not_exist', null, {
|
||||
username: item.value,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
item.user = user;
|
||||
}
|
||||
}
|
||||
]),
|
||||
new Sequence({ name: 'process shares',
|
||||
beforeEach (a) {
|
||||
const { shares_work } = a.values();
|
||||
shares_work.clear_invalid();
|
||||
}
|
||||
}, [
|
||||
function validate_share_types (a) {
|
||||
const { result, shares_work } = a.values();
|
||||
|
||||
const lib_typeTagged = a.iget('services').get('lib-type-tagged');
|
||||
|
||||
for ( const item of shares_work.list() ) {
|
||||
const { i } = item;
|
||||
let { value } = item;
|
||||
|
||||
const thing = lib_typeTagged.process(value);
|
||||
if ( thing.$ === 'error' ) {
|
||||
item.invalid = true;
|
||||
result.shares[i] =
|
||||
APIError.create('format_error', null, {
|
||||
message: thing.message
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const allowed_things = ['fs-share', 'app-share'];
|
||||
if ( ! allowed_things.includes(thing.$) ) {
|
||||
item.invalid = true;
|
||||
result.shares[i] =
|
||||
APIError.create('disallowed_thing', null, {
|
||||
thing: thing.$,
|
||||
accepted: allowed_things,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
item.thing = thing;
|
||||
}
|
||||
},
|
||||
function create_file_share_intents (a) {
|
||||
const { result, shares_work } = a.values();
|
||||
for ( const item of shares_work.list() ) {
|
||||
const { thing } = item;
|
||||
if ( thing.$ !== 'fs-share' ) continue;
|
||||
|
||||
item.type = 'fs';
|
||||
const errors = [];
|
||||
if ( ! thing.path ) {
|
||||
errors.push('`path` is required');
|
||||
}
|
||||
let access = thing.access;
|
||||
if ( access ) {
|
||||
if ( ! ['read','write'].includes(access) ) {
|
||||
errors.push('`access` should be `read` or `write`');
|
||||
}
|
||||
} else access = 'read';
|
||||
|
||||
if ( errors.length ) {
|
||||
item.invalid = true;
|
||||
result.shares[item.i] =
|
||||
APIError.create('field_errors', null, {
|
||||
key: `shares[${item.i}]`,
|
||||
errors
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
item.path = thing.path;
|
||||
item.share_intent = {
|
||||
$: 'share-intent:file',
|
||||
permissions: [PermissionUtil.join('fs', thing.path, access)],
|
||||
};
|
||||
}
|
||||
},
|
||||
function create_app_share_intents (a) {
|
||||
const { result, shares_work } = a.values();
|
||||
for ( const item of shares_work.list() ) {
|
||||
const { thing } = item;
|
||||
if ( thing.$ !== 'app-share' ) continue;
|
||||
|
||||
item.type = 'app';
|
||||
const errors = [];
|
||||
if ( ! thing.uid && ! thing.name ) {
|
||||
errors.push('`uid` or `name` is required');
|
||||
}
|
||||
|
||||
if ( errors.length ) {
|
||||
item.invalid = true;
|
||||
result.shares[item.i] =
|
||||
APIError.create('field_errors', null, {
|
||||
key: `shares[${item.i}]`,
|
||||
errors
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const app_selector = thing.uid
|
||||
? `uid#${thing.uid}` : thing.name;
|
||||
|
||||
item.share_intent = {
|
||||
$: 'share-intent:app',
|
||||
permissions: [
|
||||
PermissionUtil.join('app', app_selector, 'access')
|
||||
]
|
||||
}
|
||||
continue;
|
||||
}
|
||||
},
|
||||
async function fetch_nodes_for_file_shares (a) {
|
||||
const { req, result, shares_work } = a.values();
|
||||
for ( const item of shares_work.list() ) {
|
||||
if ( item.type !== 'fs' ) continue;
|
||||
const node = await (new FSNodeParam('path')).consolidate({
|
||||
req, getParam: () => item.path
|
||||
});
|
||||
|
||||
if ( ! await node.exists() ) {
|
||||
item.invalid = true;
|
||||
result.shares[item.i] = APIError.create('subject_does_not_exist', {
|
||||
path: item.path,
|
||||
})
|
||||
continue;
|
||||
}
|
||||
|
||||
item.node = node;
|
||||
let email_path = item.path;
|
||||
let is_dir = true;
|
||||
if ( await node.get('type') !== TYPE_DIRECTORY ) {
|
||||
is_dir = false;
|
||||
// remove last component
|
||||
email_path = email_path.slice(0, item.path.lastIndexOf('/')+1);
|
||||
}
|
||||
|
||||
if ( email_path.startsWith('/') ) email_path = email_path.slice(1);
|
||||
const email_link = `${config.origin}/show/${email_path}`;
|
||||
item.is_dir = is_dir;
|
||||
item.email_link = email_link;
|
||||
}
|
||||
},
|
||||
async function fetch_apps_for_app_shares (a) {
|
||||
const { result, shares_work } = a.values();
|
||||
const db = a.iget('db');
|
||||
|
||||
for ( const item of shares_work.list() ) {
|
||||
if ( item.type !== 'app' ) continue;
|
||||
const { thing } = item;
|
||||
|
||||
const app = await get_app(thing.uid ?
|
||||
{ uid: thing.uid } : { name: thing.name });
|
||||
if ( ! app ) {
|
||||
item.invalid = true;
|
||||
result.shares[item.i] =
|
||||
// note: since we're reporting `entity_not_found`
|
||||
// we will report the id as an entity-storage-compatible
|
||||
// identifier.
|
||||
APIError.create('entity_not_found', null, {
|
||||
identifier: thing.uid
|
||||
? { uid: thing.uid }
|
||||
: { id: { name: thing.name } }
|
||||
});
|
||||
}
|
||||
|
||||
app.metadata = db.case({
|
||||
mysql: () => app.metadata,
|
||||
otherwise: () => JSON.parse(app.metadata ?? '{}')
|
||||
})();
|
||||
|
||||
item.app = app;
|
||||
}
|
||||
},
|
||||
async function add_subdomain_permissions (a) {
|
||||
const { shares_work } = a.values();
|
||||
const actor = a.get('actor');
|
||||
const db = a.iget('db');
|
||||
|
||||
for ( const item of shares_work.list() ) {
|
||||
if ( item.type !== 'app' ) continue;
|
||||
const [subdomain] = await db.read(
|
||||
`SELECT * FROM subdomains WHERE associated_app_id = ? ` +
|
||||
`AND user_id = ? LIMIT 1`,
|
||||
[item.app.id, actor.type.user.id]
|
||||
);
|
||||
if ( ! subdomain ) continue;
|
||||
|
||||
// The subdomain is also owned by this user, so we'll
|
||||
// add a permission for that as well
|
||||
|
||||
const site_selector = `uid#${subdomain.uuid}`;
|
||||
item.share_intent.permissions.push(
|
||||
PermissionUtil.join('site', site_selector, 'access')
|
||||
)
|
||||
}
|
||||
},
|
||||
async function add_appdata_permissions (a) {
|
||||
const { result, shares_work } = a.values();
|
||||
for ( const item of shares_work.list() ) {
|
||||
if ( item.type !== 'app' ) continue;
|
||||
if ( ! item.app.metadata?.shared_appdata ) continue;
|
||||
|
||||
const app_owner = await get_user({ id: item.app.owner_user_id });
|
||||
|
||||
const appdatadir =
|
||||
`/${app_owner.username}/AppData/${item.app.uid}`;
|
||||
const appdatadir_perm =
|
||||
PermissionUtil.join('fs', appdatadir, 'write');
|
||||
|
||||
item.share_intent.permissions.push(appdatadir_perm);
|
||||
}
|
||||
},
|
||||
function apply_success_status_to_shares (a) {
|
||||
const { result, shares_work } = a.values();
|
||||
for ( const item of shares_work.list() ) {
|
||||
result.shares[item.i] =
|
||||
{
|
||||
$: 'api:status-report',
|
||||
status: 'success',
|
||||
fields: {
|
||||
permission: item.permission,
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
]),
|
||||
function abort_on_error_if_mode_is_strict (a) {
|
||||
const strict_mode = a.get('strict_mode');
|
||||
if ( ! strict_mode ) return;
|
||||
|
||||
const result = a.get('result');
|
||||
if (
|
||||
result.recipients.some(v => v !== null) ||
|
||||
result.shares.some(v => v !== null)
|
||||
) {
|
||||
result.serialize();
|
||||
result.status = 'aborted';
|
||||
const res = a.get('res');
|
||||
res.status(218).send(result);
|
||||
a.stop();
|
||||
}
|
||||
},
|
||||
function early_return_on_dry_run (a) {
|
||||
if ( ! a.get('req').body.dry_run ) return;
|
||||
|
||||
const { res, result, recipients_work } = a.values();
|
||||
for ( const item of recipients_work.list() ) {
|
||||
result.recipients[item.i] =
|
||||
{ $: 'api:status-report', status: 'success' };
|
||||
}
|
||||
|
||||
result.serialize();
|
||||
result.status = 'success';
|
||||
result.dry_run = true;
|
||||
res.send(result);
|
||||
a.stop();
|
||||
},
|
||||
async function grant_permissions_to_existing_users (a) {
|
||||
const {
|
||||
req, result, recipients_work, shares_work
|
||||
} = a.values();
|
||||
|
||||
const svc_permission = a.iget('services').get('permission');
|
||||
const svc_notification = a.iget('services').get('notification');
|
||||
|
||||
const actor = a.get('actor');
|
||||
|
||||
for ( const recipient_item of recipients_work.list() ) {
|
||||
if ( recipient_item.type !== 'username' ) continue;
|
||||
|
||||
const username = recipient_item.user.username;
|
||||
|
||||
for ( const share_item of shares_work.list() ) {
|
||||
const permissions = share_item.share_intent.permissions;
|
||||
for ( const perm of permissions ) {
|
||||
await svc_permission.grant_user_user_permission(
|
||||
actor,
|
||||
username,
|
||||
perm,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const files = []; {
|
||||
for ( const item of shares_work.list() ) {
|
||||
if ( item.type !== 'file' ) continue;
|
||||
files.push(
|
||||
await item.node.getSafeEntry(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const apps = []; {
|
||||
for ( const item of shares_work.list() ) {
|
||||
if ( item.type !== 'app' ) continue;
|
||||
// TODO: is there a general way to create a
|
||||
// client-safe app right now without
|
||||
// going through entity storage?
|
||||
// track: manual safe object
|
||||
apps.push(item.name
|
||||
? item.name : await get_app({
|
||||
uid: item.uid,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
svc_notification.notify(UsernameNotifSelector(username), {
|
||||
source: 'sharing',
|
||||
icon: 'shared.svg',
|
||||
title: 'Files were shared with you!',
|
||||
template: 'file-shared-with-you',
|
||||
fields: {
|
||||
username: actor.type.user.username,
|
||||
files,
|
||||
},
|
||||
text: `The user ${quot(req.user.username)} shared ` +
|
||||
`${files.length} ` +
|
||||
(files.length === 1 ? 'file' : 'files') + ' ' +
|
||||
'with you.',
|
||||
});
|
||||
|
||||
result.recipients[recipient_item.i] =
|
||||
{ $: 'api:status-report', status: 'success' };
|
||||
}
|
||||
},
|
||||
async function email_the_email_recipients (a) {
|
||||
const { actor, recipients_work, shares_work } = a.values();
|
||||
|
||||
const svc_share = a.iget('services').get('share');
|
||||
const svc_token = a.iget('services').get('token');
|
||||
const svc_email = a.iget('services').get('token');
|
||||
|
||||
for ( const recipient_item of recipients_work.list() ) {
|
||||
if ( recipient_item.type !== 'email' ) continue;
|
||||
|
||||
const email = recipient_item.value;
|
||||
|
||||
// data that gets stored in the `data` column of the share
|
||||
const data = {
|
||||
$: 'internal:share',
|
||||
$v: 'v0.0.0',
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
for ( const share_item of shares_work.list() ) {
|
||||
const permissions = share_item.share_intent.permissions;
|
||||
data.permissions.push(...permissions);
|
||||
}
|
||||
|
||||
// track: scoping iife
|
||||
const share_token = await (async () => {
|
||||
const share_uid = await svc_share.create_share({
|
||||
issuer: actor,
|
||||
email,
|
||||
data,
|
||||
});
|
||||
return svc_token.sign('share', {
|
||||
$: 'token:share',
|
||||
$v: '0.0.0',
|
||||
uid: share_uid,
|
||||
}, {
|
||||
expiresIn: '14d'
|
||||
});
|
||||
})();
|
||||
|
||||
const email_link =
|
||||
`${config.origin}?share_token=${share_token}`;
|
||||
|
||||
await svc_email.send_email({ email }, 'share_by_email', {
|
||||
link: email_link,
|
||||
});
|
||||
}
|
||||
},
|
||||
function send_result (a) {
|
||||
const { res, result } = a.values();
|
||||
result.serialize();
|
||||
res.send(result);
|
||||
}
|
||||
]);
|
Loading…
Reference in New Issue
Block a user