mirror of
https://github.com/meteor/meteor.git
synced 2026-05-02 03:01:46 -04:00
Merge branch 'modern-bundler-integration' of https://github.com/meteor/meteor into modern-bundler-integration
This commit is contained in:
@@ -8,6 +8,76 @@
|
||||
|
||||
[//]: # (go to meteor/docs/generators/changelog/docs)
|
||||
|
||||
## v3.3.2, 01-09-2025
|
||||
|
||||
### Highlights
|
||||
|
||||
- Async-compatible account URLs and email-sending coverage [#13740](https://github.com/meteor/meteor/pull/13740)
|
||||
- Move `findUserByEmail` method from `accounts-password` to `accounts-base` [#13859](https://github.com/meteor/meteor/pull/13859)
|
||||
- Return `insertedId` on client `upsert` to match Meteor 2.x behavior [#13891](https://github.com/meteor/meteor/pull/13891)
|
||||
- Unrecognized operator bug fixed [#13895](https://github.com/meteor/meteor/pull/13895)
|
||||
- Security fix for `sha.js` [#13908](https://github.com/meteor/meteor/pull/13908)
|
||||
|
||||
|
||||
All Merged PRs@[GitHub PRs 3.3.2](https://github.com/meteor/meteor/pulls?q=is%3Apr+is%3Amerged+base%3Arelease-3.3.2)
|
||||
|
||||
#### Breaking Changes
|
||||
|
||||
N/A
|
||||
|
||||
##### Cordova Upgrade
|
||||
|
||||
- Enable modern browser support for Cordova unless explicitly disabled [#13896](https://github.com/meteor/meteor/pull/13896)
|
||||
|
||||
#### Internal API changes
|
||||
|
||||
- lodash.template dependency was removed [#13898](https://github.com/meteor/meteor/pull/13898)
|
||||
|
||||
#### Migration Steps
|
||||
|
||||
Please run the following command to update your project:
|
||||
|
||||
```bash
|
||||
meteor update --release 3.3.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
If you find any issues, please report them to the [Meteor issues tracker](https://github.com/meteor/meteor).
|
||||
|
||||
#### Bumped Meteor Packages
|
||||
|
||||
- accounts-base@3.1.2
|
||||
- accounts-password@3.2.1
|
||||
- accounts-passwordless@3.0.2
|
||||
- meteor-node-stubs@1.2.24
|
||||
- babel-compiler@7.12.2
|
||||
- boilerplate-generator@2.0.2
|
||||
- ecmascript@0.16.13
|
||||
- minifier@3.0.4
|
||||
- minimongo@2.0.4
|
||||
- mongo@2.1.4
|
||||
- coffeescript-compiler@2.4.3
|
||||
- npm-mongo@6.16.1
|
||||
- shell-server@0.6.2
|
||||
- typescript@5.6.6
|
||||
|
||||
#### Bumped NPM Packages
|
||||
|
||||
- meteor-node-stubs@1.2.23
|
||||
|
||||
#### Special thanks to
|
||||
|
||||
✨✨✨
|
||||
|
||||
- [@italojs](https://github.com/italojs)
|
||||
- [@nachocodoner](https://github.com/nachocodoner)
|
||||
- [@graemian](https://github.com/graemian)
|
||||
- [@Grubba27](https://github.com/Grubba27)
|
||||
- [@copleykj](https://github.com/copleykj)
|
||||
|
||||
✨✨✨
|
||||
|
||||
## v3.3.1, 05-08-2025
|
||||
|
||||
### Highlights
|
||||
|
||||
2
meteor
2
meteor
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
BUNDLE_VERSION=22.18.0.20
|
||||
BUNDLE_VERSION=22.18.0.21
|
||||
|
||||
# OS Check. Put here because here is where we download the precompiled
|
||||
# bundles that are arch specific.
|
||||
|
||||
44
npm-packages/meteor-node-stubs/package-lock.json
generated
44
npm-packages/meteor-node-stubs/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meteor-node-stubs",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.23",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meteor-node-stubs",
|
||||
"version": "1.2.13",
|
||||
"version": "1.2.23",
|
||||
"bundleDependencies": [
|
||||
"@meteorjs/crypto-browserify",
|
||||
"assert",
|
||||
@@ -41,7 +41,6 @@
|
||||
"console-browserify": "^1.2.0",
|
||||
"constants-browserify": "^1.0.0",
|
||||
"domain-browser": "^4.23.0",
|
||||
"elliptic": "^6.6.1",
|
||||
"events": "^3.3.0",
|
||||
"https-browserify": "^1.0.0",
|
||||
"os-browserify": "^0.3.0",
|
||||
@@ -50,6 +49,7 @@
|
||||
"punycode": "^1.4.1",
|
||||
"querystring-es3": "^0.2.1",
|
||||
"readable-stream": "^3.6.2",
|
||||
"sha.js": "^2.4.12",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"stream-http": "^3.2.0",
|
||||
"string_decoder": "^1.3.0",
|
||||
@@ -695,27 +695,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/elliptic": {
|
||||
"version": "6.6.1",
|
||||
"resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz",
|
||||
"integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.11.9",
|
||||
"brorand": "^1.1.0",
|
||||
"hash.js": "^1.0.0",
|
||||
"hmac-drbg": "^1.0.1",
|
||||
"inherits": "^2.0.4",
|
||||
"minimalistic-assert": "^1.0.1",
|
||||
"minimalistic-crypto-utils": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/elliptic/node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
@@ -1646,17 +1625,24 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sha.js": {
|
||||
"version": "2.4.11",
|
||||
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
|
||||
"integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
|
||||
"version": "2.4.12",
|
||||
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz",
|
||||
"integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==",
|
||||
"inBundle": true,
|
||||
"license": "(MIT AND BSD-3-Clause)",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
"inherits": "^2.0.4",
|
||||
"safe-buffer": "^5.2.1",
|
||||
"to-buffer": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"sha.js": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "meteor-node-stubs",
|
||||
"author": "Ben Newman <ben@meteor.com>",
|
||||
"description": "Stub implementations of Node built-in modules, a la Browserify",
|
||||
"version": "1.2.21",
|
||||
"version": "1.2.24",
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"homepage": "https://github.com/meteor/meteor/blob/devel/npm-packages/meteor-node-stubs/README.md",
|
||||
@@ -18,7 +18,6 @@
|
||||
"console-browserify": "^1.2.0",
|
||||
"constants-browserify": "^1.0.0",
|
||||
"domain-browser": "^4.23.0",
|
||||
"elliptic": "^6.6.1",
|
||||
"events": "^3.3.0",
|
||||
"https-browserify": "^1.0.0",
|
||||
"os-browserify": "^0.3.0",
|
||||
@@ -27,6 +26,7 @@
|
||||
"punycode": "^1.4.1",
|
||||
"querystring-es3": "^0.2.1",
|
||||
"readable-stream": "^3.6.2",
|
||||
"sha.js": "^2.4.12",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"stream-http": "^3.2.0",
|
||||
"string_decoder": "^1.3.0",
|
||||
@@ -81,5 +81,30 @@
|
||||
],
|
||||
"bugs": {
|
||||
"url": "https://github.com/meteor/node-stubs/issues"
|
||||
}
|
||||
},
|
||||
"bundleDependencies": [
|
||||
"@meteorjs/crypto-browserify",
|
||||
"assert",
|
||||
"browserify-zlib",
|
||||
"buffer",
|
||||
"console-browserify",
|
||||
"constants-browserify",
|
||||
"domain-browser",
|
||||
"events",
|
||||
"https-browserify",
|
||||
"os-browserify",
|
||||
"path-browserify",
|
||||
"process",
|
||||
"punycode",
|
||||
"querystring-es3",
|
||||
"readable-stream",
|
||||
"stream-browserify",
|
||||
"stream-http",
|
||||
"string_decoder",
|
||||
"timers-browserify",
|
||||
"tty-browserify",
|
||||
"url",
|
||||
"util",
|
||||
"vm-browserify"
|
||||
]
|
||||
}
|
||||
|
||||
24
packages/accounts-base/accounts-base.d.ts
vendored
24
packages/accounts-base/accounts-base.d.ts
vendored
@@ -6,6 +6,7 @@ import { DDP } from 'meteor/ddp';
|
||||
export interface URLS {
|
||||
resetPassword: (token: string) => string;
|
||||
verifyEmail: (token: string) => string;
|
||||
loginToken: (token: string) => string;
|
||||
enrollAccount: (token: string) => string;
|
||||
}
|
||||
|
||||
@@ -204,26 +205,43 @@ export namespace Accounts {
|
||||
options?: { fields?: Mongo.FieldSpecifier | undefined }
|
||||
): Promise<Meteor.User | null | undefined>;
|
||||
|
||||
interface SendEmailOptions {
|
||||
from: string;
|
||||
to: string;
|
||||
subject: string;
|
||||
text: string;
|
||||
html: string;
|
||||
headers?: Header | undefined;
|
||||
}
|
||||
|
||||
interface SendEmailResult {
|
||||
email: string;
|
||||
user: Meteor.User;
|
||||
token: string;
|
||||
url: string;
|
||||
options: SendEmailOptions;
|
||||
}
|
||||
|
||||
function sendEnrollmentEmail(
|
||||
userId: string,
|
||||
email?: string,
|
||||
extraTokenData?: Record<string, unknown>,
|
||||
extraParams?: Record<string, unknown>
|
||||
): Promise<void>;
|
||||
): Promise<SendEmailResult>;
|
||||
|
||||
function sendResetPasswordEmail(
|
||||
userId: string,
|
||||
email?: string,
|
||||
extraTokenData?: Record<string, unknown>,
|
||||
extraParams?: Record<string, unknown>
|
||||
): Promise<void>;
|
||||
): Promise<SendEmailResult>;
|
||||
|
||||
function sendVerificationEmail(
|
||||
userId: string,
|
||||
email?: string,
|
||||
extraTokenData?: Record<string, unknown>,
|
||||
extraParams?: Record<string, unknown>
|
||||
): Promise<void>;
|
||||
): Promise<SendEmailResult>;
|
||||
|
||||
function setUsername(userId: string, newUsername: string): Promise<void>;
|
||||
|
||||
|
||||
@@ -84,6 +84,11 @@ export class AccountsServer extends AccountsCommon {
|
||||
|
||||
this._skipCaseInsensitiveChecksForTest = {};
|
||||
|
||||
// Helper function to resolve promises if needed
|
||||
this._resolvePromise = async (value) => {
|
||||
return Meteor._isPromise(value) ? await value : value;
|
||||
};
|
||||
|
||||
this.urls = {
|
||||
resetPassword: (token, extraParams) => this.buildEmailUrl(`#/reset-password/${token}`, extraParams),
|
||||
verifyEmail: (token, extraParams) => this.buildEmailUrl(`#/verify-email/${token}`, extraParams),
|
||||
@@ -333,6 +338,32 @@ export class AccountsServer extends AccountsCommon {
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Find a user by one of their email addresses.
|
||||
* @locus Server
|
||||
* @param {String} email The email address to look for
|
||||
* @param {Object} [options]
|
||||
* @param {Object} options.fields Limit the fields to return from the user document
|
||||
* @returns {Promise<Object>} A user if found, else null
|
||||
* @memberof Accounts
|
||||
* @importFromPackage accounts-base
|
||||
*/
|
||||
findUserByEmail = async (email, options) =>
|
||||
await this._findUserByQuery({ email }, options);
|
||||
|
||||
/**
|
||||
* @summary Find a user by their username.
|
||||
* @locus Server
|
||||
* @param {String} username The username to look for
|
||||
* @param {Object} [options]
|
||||
* @param {Object} options.fields Limit the fields to return from the user document
|
||||
* @returns {Promise<Object>} A user if found, else null
|
||||
* @memberof Accounts
|
||||
* @importFromPackage accounts-base
|
||||
*/
|
||||
findUserByUsername = async (username, options) =>
|
||||
await this._findUserByQuery({ username }, options);
|
||||
|
||||
///
|
||||
/// LOGIN METHODS
|
||||
///
|
||||
|
||||
@@ -775,8 +775,8 @@ if (Meteor.isServer) {
|
||||
});
|
||||
});
|
||||
|
||||
Tinytest.add(
|
||||
'accounts - make sure that extra params to accounts urls are added',
|
||||
Tinytest.addAsync(
|
||||
'accounts - urls work with sync resolution',
|
||||
async test => {
|
||||
// No extra params
|
||||
const verifyEmailURL = new URL(Accounts.urls.verifyEmail('test'));
|
||||
@@ -790,6 +790,49 @@ if (Meteor.isServer) {
|
||||
test.equal(enrollAccountURL.searchParams.get('test'), extraParams.test);
|
||||
}
|
||||
);
|
||||
|
||||
Tinytest.addAsync(
|
||||
'accounts - urls work with async resolution',
|
||||
async test => {
|
||||
// Save original urls
|
||||
const originalUrls = Accounts.urls;
|
||||
try {
|
||||
// Override urls methods to return Promises
|
||||
Accounts.urls = {
|
||||
resetPassword: (token, extraParams) =>
|
||||
new Promise(resolve => resolve(originalUrls.resetPassword(token, extraParams))),
|
||||
verifyEmail: (token, extraParams) =>
|
||||
new Promise(resolve => resolve(originalUrls.verifyEmail(token, extraParams))),
|
||||
loginToken: (selector, token, extraParams) =>
|
||||
new Promise(resolve => resolve(originalUrls.loginToken(selector, token, extraParams))),
|
||||
enrollAccount: (token, extraParams) =>
|
||||
new Promise(resolve => resolve(originalUrls.enrollAccount(token, extraParams))),
|
||||
};
|
||||
|
||||
// Test with no extra params
|
||||
const verifyEmailUrl = await Accounts.urls.verifyEmail('test');
|
||||
const verifyEmailURL = new URL(verifyEmailUrl);
|
||||
test.equal(verifyEmailURL.searchParams.toString(), "");
|
||||
|
||||
// Test with extra params
|
||||
const extraParams = { test: 'async-success' };
|
||||
const resetPasswordUrl = await Accounts.urls.resetPassword('test', extraParams);
|
||||
const resetPasswordURL = new URL(resetPasswordUrl);
|
||||
test.equal(resetPasswordURL.searchParams.get('test'), extraParams.test);
|
||||
|
||||
const enrollAccountUrl = await Accounts.urls.enrollAccount('test', extraParams);
|
||||
const enrollAccountURL = new URL(enrollAccountUrl);
|
||||
test.equal(enrollAccountURL.searchParams.get('test'), extraParams.test);
|
||||
|
||||
const loginTokenUrl = await Accounts.urls.loginToken('email', 'token', extraParams);
|
||||
const loginTokenURL = new URL(loginTokenUrl);
|
||||
test.equal(loginTokenURL.searchParams.get('test'), extraParams.test);
|
||||
} finally {
|
||||
// Restore original urls
|
||||
Accounts.urls = originalUrls;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Tinytest.addAsync('accounts - updateOrCreateUserFromExternalService - Facebook', async test => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Package.describe({
|
||||
summary: "A user account system",
|
||||
version: "3.1.1",
|
||||
version: "3.1.2",
|
||||
});
|
||||
|
||||
Package.onUse((api) => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { AccountsServer } from "./accounts_server.js";
|
||||
Accounts = new AccountsServer(Meteor.server, { ...Meteor.settings.packages?.accounts, ...Meteor.settings.packages?.['accounts-base'] });
|
||||
// TODO[FIBERS]: I need TLA
|
||||
Accounts.init().then();
|
||||
|
||||
// Users table. Don't use the normal autopublish, since we want to hide
|
||||
// some fields. Code to autopublish this is in accounts_server.js.
|
||||
// XXX Allow users to configure this collection name.
|
||||
|
||||
@@ -5,7 +5,7 @@ Package.describe({
|
||||
// 2.2.x in the future. The version was also bumped to 2.0.0 temporarily
|
||||
// during the Meteor 1.5.1 release process, so versions 2.0.0-beta.2
|
||||
// through -beta.5 and -rc.0 have already been published.
|
||||
version: "3.2.0",
|
||||
version: "3.2.1",
|
||||
});
|
||||
|
||||
Npm.depends({
|
||||
|
||||
@@ -287,37 +287,6 @@ Accounts._checkPasswordAsync = checkPasswordAsync;
|
||||
///
|
||||
|
||||
|
||||
/**
|
||||
* @summary Finds the user asynchronously with the specified username.
|
||||
* First tries to match username case sensitively; if that fails, it
|
||||
* tries case insensitively; but if more than one user matches the case
|
||||
* insensitive search, it returns null.
|
||||
* @locus Server
|
||||
* @param {String} username The username to look for
|
||||
* @param {Object} [options]
|
||||
* @param {MongoFieldSpecifier} options.fields Dictionary of fields to return or exclude.
|
||||
* @returns {Promise<Object>} A user if found, else null
|
||||
* @importFromPackage accounts-base
|
||||
*/
|
||||
Accounts.findUserByUsername =
|
||||
async (username, options) =>
|
||||
await Accounts._findUserByQuery({ username }, options);
|
||||
|
||||
/**
|
||||
* @summary Finds the user asynchronously with the specified email.
|
||||
* First tries to match email case sensitively; if that fails, it
|
||||
* tries case insensitively; but if more than one user matches the case
|
||||
* insensitive search, it returns null.
|
||||
* @locus Server
|
||||
* @param {String} email The email address to look for
|
||||
* @param {Object} [options]
|
||||
* @param {MongoFieldSpecifier} options.fields Dictionary of fields to return or exclude.
|
||||
* @returns {Promise<Object>} A user if found, else null
|
||||
* @importFromPackage accounts-base
|
||||
*/
|
||||
Accounts.findUserByEmail =
|
||||
async (email, options) =>
|
||||
await Accounts._findUserByQuery({ email }, options);
|
||||
|
||||
// XXX maybe this belongs in the check package
|
||||
const NonEmptyString = Match.Where(x => {
|
||||
@@ -715,7 +684,7 @@ Accounts.sendResetPasswordEmail =
|
||||
async (userId, email, extraTokenData, extraParams) => {
|
||||
const { email: realEmail, user, token } =
|
||||
await Accounts.generateResetToken(userId, email, 'resetPassword', extraTokenData);
|
||||
const url = Accounts.urls.resetPassword(token, extraParams);
|
||||
const url = await Accounts._resolvePromise(Accounts.urls.resetPassword(token, extraParams));
|
||||
const options = await Accounts.generateOptionsForEmail(realEmail, user, url, 'resetPassword');
|
||||
await Email.sendAsync(options);
|
||||
|
||||
@@ -749,7 +718,7 @@ Accounts.sendEnrollmentEmail =
|
||||
const { email: realEmail, user, token } =
|
||||
await Accounts.generateResetToken(userId, email, 'enrollAccount', extraTokenData);
|
||||
|
||||
const url = Accounts.urls.enrollAccount(token, extraParams);
|
||||
const url = await Accounts._resolvePromise(Accounts.urls.enrollAccount(token, extraParams));
|
||||
|
||||
const options =
|
||||
await Accounts.generateOptionsForEmail(realEmail, user, url, 'enrollAccount');
|
||||
@@ -938,7 +907,7 @@ Accounts.sendVerificationEmail =
|
||||
|
||||
const { email: realEmail, user, token } =
|
||||
await Accounts.generateVerificationToken(userId, email, extraTokenData);
|
||||
const url = Accounts.urls.verifyEmail(token, extraParams);
|
||||
const url = await Accounts._resolvePromise(Accounts.urls.verifyEmail(token, extraParams));
|
||||
const options = await Accounts.generateOptionsForEmail(realEmail, user, url, 'verifyEmail');
|
||||
await Email.sendAsync(options);
|
||||
if (Meteor.isDevelopment && !Meteor.isPackageTest) {
|
||||
@@ -1345,4 +1314,3 @@ await Meteor.users.createIndexAsync('services.password.reset.token',
|
||||
{ unique: true, sparse: true });
|
||||
await Meteor.users.createIndexAsync('services.password.enroll.token',
|
||||
{ unique: true, sparse: true });
|
||||
|
||||
|
||||
@@ -1789,7 +1789,7 @@ if (Meteor.isServer) (() => {
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
Tinytest.addAsync("accounts emails - replace email", async test => {
|
||||
const origEmail = `originalemail@test.com`;
|
||||
@@ -1811,7 +1811,7 @@ Tinytest.addAsync("accounts emails - replace email", async test => {
|
||||
{ address: newEmail, verified: false }
|
||||
]);
|
||||
})
|
||||
|
||||
|
||||
Tinytest.addAsync("passwords - remove email",
|
||||
async test => {
|
||||
const origEmail = `${ Random.id() }@turing.com`;
|
||||
@@ -1947,4 +1947,105 @@ Tinytest.addAsync("accounts emails - replace email", async test => {
|
||||
});
|
||||
}, 'already exists');
|
||||
});
|
||||
|
||||
Tinytest.addAsync('passwords - send email functions', async test => {
|
||||
// Create a user with an unverified email
|
||||
const username = Random.id();
|
||||
const email = `${username}-intercept@example.com`;
|
||||
const password = 'password';
|
||||
|
||||
const userId = await Accounts.createUserAsync({
|
||||
username: username,
|
||||
email: email,
|
||||
password: password
|
||||
});
|
||||
|
||||
test.isTrue(userId, 'User ID should be returned');
|
||||
|
||||
// Mock Email.sendAsync to track if it was called
|
||||
const originalSendAsync = Email.sendAsync;
|
||||
let emailSent = 0;
|
||||
Email.sendAsync = async (options) => {
|
||||
emailSent++;
|
||||
return originalSendAsync(options);
|
||||
};
|
||||
|
||||
try {
|
||||
// Test sendVerificationEmail
|
||||
const verificationResult = await Accounts.sendVerificationEmail(userId, email);
|
||||
|
||||
// Verify the result contains expected properties
|
||||
test.isTrue(verificationResult, 'Result should be returned for verification email');
|
||||
test.equal(verificationResult.email, email, 'Email in verification result should match');
|
||||
test.isTrue(verificationResult.user, 'User object should be in verification result');
|
||||
test.isTrue(verificationResult.token, 'Token should be in verification result');
|
||||
test.isTrue(verificationResult.url, 'URL should be in verification result');
|
||||
test.isTrue(verificationResult.options, 'Email options should be in verification result');
|
||||
|
||||
// Test sendEnrollmentEmail
|
||||
const enrollmentResult = await Accounts.sendEnrollmentEmail(userId, email);
|
||||
|
||||
// Verify the result contains expected properties
|
||||
test.isTrue(enrollmentResult, 'Result should be returned for enrollment email');
|
||||
test.equal(enrollmentResult.email, email, 'Email in enrollment result should match');
|
||||
test.isTrue(enrollmentResult.user, 'User object should be in enrollment result');
|
||||
test.isTrue(enrollmentResult.token, 'Token should be in enrollment result');
|
||||
test.isTrue(enrollmentResult.url, 'URL should be in enrollment result');
|
||||
test.isTrue(enrollmentResult.options, 'Email options should be in enrollment result');
|
||||
|
||||
// Test sendResetPasswordEmail
|
||||
const resetResult = await Accounts.sendResetPasswordEmail(userId, email);
|
||||
|
||||
// Verify the result contains expected properties
|
||||
test.isTrue(resetResult, 'Result should be returned for reset password email');
|
||||
test.equal(resetResult.email, email, 'Email in reset result should match');
|
||||
test.isTrue(resetResult.user, 'User object should be in reset result');
|
||||
test.isTrue(resetResult.token, 'Token should be in reset result');
|
||||
test.isTrue(resetResult.url, 'URL should be in reset result');
|
||||
test.isTrue(resetResult.options, 'Email options should be in reset result');
|
||||
|
||||
// Verify Email.sendAsync was called for all three emails
|
||||
test.equal(emailSent, 3, 'Email.sendAsync should have been called three times');
|
||||
|
||||
// Get the intercepted emails
|
||||
const interceptedEmails = await Meteor.callAsync("getInterceptedEmails", email);
|
||||
test.equal(interceptedEmails.length, 3, 'Three emails should have been intercepted');
|
||||
|
||||
// Verify the verification email content
|
||||
const verificationEmailOptions = interceptedEmails[0];
|
||||
test.isTrue(verificationEmailOptions, 'Verification email should have been intercepted');
|
||||
const verificationRe = new RegExp(`${Meteor.absoluteUrl()}#/verify-email/(\\S*)`);
|
||||
const verificationMatch = verificationEmailOptions.text.match(verificationRe);
|
||||
test.isTrue(verificationMatch, 'Verification email should contain verification URL');
|
||||
const verificationTokenFromUrl = verificationMatch[1];
|
||||
test.isTrue(verificationResult.url.includes(verificationTokenFromUrl), 'Verification URL in result should contain the token');
|
||||
|
||||
// Verify the enrollment email content
|
||||
const enrollmentEmailOptions = interceptedEmails[1];
|
||||
test.isTrue(enrollmentEmailOptions, 'Enrollment email should have been intercepted');
|
||||
const enrollmentRe = new RegExp(`${Meteor.absoluteUrl()}#/enroll-account/(\\S*)`);
|
||||
const enrollmentMatch = enrollmentEmailOptions.text.match(enrollmentRe);
|
||||
test.isTrue(enrollmentMatch, 'Enrollment email should contain enrollment URL');
|
||||
const enrollmentTokenFromUrl = enrollmentMatch[1];
|
||||
test.isTrue(enrollmentResult.url.includes(enrollmentTokenFromUrl), 'Enrollment URL in result should contain the token');
|
||||
|
||||
// Verify the reset password email content
|
||||
const resetEmailOptions = interceptedEmails[2];
|
||||
test.isTrue(resetEmailOptions, 'Reset password email should have been intercepted');
|
||||
const resetRe = new RegExp(`${Meteor.absoluteUrl()}#/reset-password/(\\S*)`);
|
||||
const resetMatch = resetEmailOptions.text.match(resetRe);
|
||||
test.isTrue(resetMatch, 'Reset password email should contain reset URL');
|
||||
const resetTokenFromUrl = resetMatch[1];
|
||||
test.isTrue(resetResult.url.includes(resetTokenFromUrl), 'Reset URL in result should contain the token');
|
||||
|
||||
// Verify email headers and from address for all emails
|
||||
for (const emailOptions of interceptedEmails) {
|
||||
test.equal(emailOptions.from, 'test@meteor.com', 'From address should match');
|
||||
test.equal(emailOptions.headers['My-Custom-Header'], 'Cool', 'Custom header should be present');
|
||||
}
|
||||
} finally {
|
||||
// Restore the original Email.sendAsync
|
||||
Email.sendAsync = originalSendAsync;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Package.describe({
|
||||
summary: 'No-password login/sign-up support for accounts',
|
||||
version: '3.0.1',
|
||||
version: '3.0.2',
|
||||
});
|
||||
|
||||
Package.onUse(api => {
|
||||
|
||||
@@ -220,7 +220,7 @@ Meteor.methods({
|
||||
*/
|
||||
Accounts.sendLoginTokenEmail = async ({ userId, sequence, email, extra = {} }) => {
|
||||
const user = await getUserById(userId);
|
||||
const url = Accounts.urls.loginToken(email, sequence, extra);
|
||||
const url = await Accounts._resolvePromise(Accounts.urls.loginToken(email, sequence, extra));
|
||||
const options = await Accounts.generateOptionsForEmail(
|
||||
email,
|
||||
user,
|
||||
|
||||
@@ -287,6 +287,8 @@ BCp.processOneFileForTarget = function (inputFile, source) {
|
||||
features.nodeMajorVersion = parseInt(process.versions.node, 10);
|
||||
} else if (arch === "web.browser") {
|
||||
features.modernBrowsers = true;
|
||||
} else if (arch === "web.cordova") {
|
||||
features.modernBrowsers = ! getMeteorConfig()?.cordova?.disableModern;
|
||||
}
|
||||
|
||||
features.topLevelAwait = inputFile.supportsTopLevelAwait &&
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
Package.describe({
|
||||
name: "babel-compiler",
|
||||
summary: "Parser/transpiler for ECMAScript 2015+ syntax",
|
||||
version: '7.12.1',
|
||||
version: '7.12.2',
|
||||
});
|
||||
|
||||
Npm.depends({
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
Package.describe({
|
||||
summary: "Generates the boilerplate html from program's manifest",
|
||||
version: '2.0.1',
|
||||
version: '2.0.2',
|
||||
});
|
||||
|
||||
Npm.depends({
|
||||
"combined-stream2": "1.1.2",
|
||||
"lodash.template": "4.5.0"
|
||||
"combined-stream2": "1.1.2"
|
||||
});
|
||||
|
||||
Package.onUse(api => {
|
||||
|
||||
@@ -1,14 +1,134 @@
|
||||
import lodashTemplate from 'lodash.template';
|
||||
/**
|
||||
* Internal full-featured implementation of lodash.template (inspired by v4.5.0)
|
||||
* embedded to eliminate the external dependency while preserving functionality.
|
||||
*
|
||||
* MIT License (c) JS Foundation and other contributors <https://js.foundation/>
|
||||
* Adapted for Meteor boilerplate-generator (only the pieces required by template were extracted).
|
||||
*/
|
||||
|
||||
// As identified in issue #9149, when an application overrides the default
|
||||
// _.template settings using _.templateSettings, those new settings are
|
||||
// used anywhere _.template is used, including within the
|
||||
// boilerplate-generator. To handle this, _.template settings that have
|
||||
// been verified to work are overridden here on each _.template call.
|
||||
export default function template(text) {
|
||||
return lodashTemplate(text, null, {
|
||||
evaluate : /<%([\s\S]+?)%>/g,
|
||||
interpolate : /<%=([\s\S]+?)%>/g,
|
||||
escape : /<%-([\s\S]+?)%>/g,
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility & regex definitions (mirroring lodash pieces used by template)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const reEmptyStringLeading = /\b__p \+= '';/g;
|
||||
const reEmptyStringMiddle = /\b(__p \+=) '' \+/g;
|
||||
const reEmptyStringTrailing = /(__e\(.*?\)|\b__t\)) \+\n'';/g;
|
||||
|
||||
const reEscape = /<%-([\s\S]+?)%>/g; // escape delimiter
|
||||
const reEvaluate = /<%([\s\S]+?)%>/g; // evaluate delimiter
|
||||
const reInterpolate = /<%=([\s\S]+?)%>/g; // interpolate delimiter
|
||||
const reEsTemplate = /\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g; // ES6 template literal capture
|
||||
const reUnescapedString = /['\\\n\r\u2028\u2029]/g; // string literal escapes
|
||||
|
||||
// HTML escape
|
||||
const htmlEscapes = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||||
const reHasUnescapedHtml = /[&<>"']/;
|
||||
|
||||
function escapeHtml(string) {
|
||||
return string && reHasUnescapedHtml.test(string)
|
||||
? string.replace(/[&<>"']/g, chr => htmlEscapes[chr])
|
||||
: (string || '');
|
||||
}
|
||||
|
||||
// Escape characters for inclusion into a string literal
|
||||
const escapes = { "'": "'", '\\': '\\', '\n': 'n', '\r': 'r', '\u2028': 'u2028', '\u2029': 'u2029' };
|
||||
function escapeStringChar(match) { return '\\' + escapes[match]; }
|
||||
|
||||
// Basic Object helpers ------------------------------------------------------
|
||||
function isObject(value) { return value != null && typeof value === 'object'; }
|
||||
function toStringSafe(value) { return value == null ? '' : (value + ''); }
|
||||
function baseValues(object, props) { return props.map(k => object[k]); }
|
||||
|
||||
|
||||
function attempt(fn) {
|
||||
try { return fn(); } catch (e) { return e; }
|
||||
}
|
||||
function isError(value) { return value instanceof Error || (isObject(value) && value.name === 'Error'); }
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main template implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
let templateCounter = -1; // used for sourceURL generation
|
||||
|
||||
function _template(string) {
|
||||
string = toStringSafe(string);
|
||||
|
||||
const imports = { '_': { escape: escapeHtml } };
|
||||
const importKeys = Object.keys(imports);
|
||||
const importValues = baseValues(imports, importKeys);
|
||||
|
||||
let index = 0;
|
||||
let isEscaping;
|
||||
let isEvaluating;
|
||||
let source = "__p += '";
|
||||
|
||||
|
||||
// Build combined regex of delimiters
|
||||
const reDelimiters = RegExp(
|
||||
reEscape.source + '|' +
|
||||
reInterpolate.source + '|' +
|
||||
reEsTemplate.source + '|' +
|
||||
reEvaluate.source + '|$'
|
||||
, 'g');
|
||||
|
||||
const sourceURL = `//# sourceURL=lodash.templateSources[${++templateCounter}]\n`;
|
||||
|
||||
// Tokenize
|
||||
string.replace(reDelimiters, function(match, escapeValue, interpolateValue, esTemplateValue, evaluateValue, offset) {
|
||||
interpolateValue || (interpolateValue = esTemplateValue);
|
||||
// Append preceding string portion with escaped literal chars
|
||||
source += string.slice(index, offset).replace(reUnescapedString, escapeStringChar);
|
||||
if (escapeValue) {
|
||||
isEscaping = true;
|
||||
source += "' +\n__e(" + escapeValue + ") +\n'";
|
||||
}
|
||||
if (evaluateValue) {
|
||||
isEvaluating = true;
|
||||
source += "';\n" + evaluateValue + ";\n__p += '";
|
||||
}
|
||||
if (interpolateValue) {
|
||||
source += "' +\n((__t = (" + interpolateValue + ")) == null ? '' : __t) +\n'";
|
||||
}
|
||||
index = offset + match.length;
|
||||
return match;
|
||||
});
|
||||
};
|
||||
|
||||
source += "';\n";
|
||||
|
||||
source = 'with (obj) {\n' + source + '\n}\n';
|
||||
|
||||
// Remove unnecessary concatenations
|
||||
source = (isEvaluating ? source.replace(reEmptyStringLeading, '') : source)
|
||||
.replace(reEmptyStringMiddle, '$1')
|
||||
.replace(reEmptyStringTrailing, '$1;');
|
||||
|
||||
// Frame as function body
|
||||
source = 'function(obj) {\n' +
|
||||
'obj || (obj = {});\n' +
|
||||
"var __t, __p = ''" +
|
||||
(isEscaping ? ', __e = _.escape' : '') +
|
||||
(isEvaluating
|
||||
? ', __j = Array.prototype.join;\nfunction print() { __p += __j.call(arguments, \'\') }\n'
|
||||
: ';\n'
|
||||
) +
|
||||
source +
|
||||
'return __p\n}';
|
||||
|
||||
// Actual compile step
|
||||
const result = attempt(function() {
|
||||
return Function(importKeys, sourceURL + 'return ' + source).apply(undefined, importValues); // eslint-disable-line no-new-func
|
||||
});
|
||||
|
||||
if (isError(result)) {
|
||||
result.source = source; // expose for debugging if error
|
||||
throw result;
|
||||
}
|
||||
// Expose compiled source
|
||||
result.source = source;
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function template(text) {
|
||||
return _template(text);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Package.describe({
|
||||
name: 'ecmascript',
|
||||
version: '0.16.12',
|
||||
version: '0.16.13',
|
||||
summary: 'Compiler plugin that supports ES2015+ in all .js files',
|
||||
documentation: 'README.md',
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Package.describe({
|
||||
summary: "JavaScript minifier",
|
||||
version: '3.0.3',
|
||||
version: '3.0.4',
|
||||
});
|
||||
|
||||
Npm.depends({
|
||||
|
||||
@@ -678,6 +678,7 @@ export default class LocalCollection {
|
||||
|
||||
return this.finishUpdate({
|
||||
options,
|
||||
insertedId,
|
||||
updateCount,
|
||||
callback,
|
||||
selector,
|
||||
|
||||
@@ -58,3 +58,31 @@ Tinytest.add('minimongo - wrapTransform', test => {
|
||||
});
|
||||
handle.stop();
|
||||
});
|
||||
|
||||
if (Meteor.isClient) {
|
||||
Tinytest.add('minimongo - $geoIntersects should throw error', function(test) {
|
||||
const collection = new LocalCollection();
|
||||
collection.insert({ _id: 'a', loc: { type: 'Point', coordinates: [0, 0] } });
|
||||
|
||||
const query = {
|
||||
loc: {
|
||||
$geoIntersects: {
|
||||
$geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0], [0, 1], [1, 1], [1, 0], [0, 0]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test.throws(
|
||||
() => collection.findOne(query),
|
||||
/Unrecognized operator: \$geoIntersects/,
|
||||
'Should throw error for $geoIntersects in Minimongo'
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -4010,3 +4010,55 @@ Tinytest.addAsync('minimongo - asyncIterator', async (test) => {
|
||||
test.equal(itemIds.length, 2);
|
||||
test.equal(itemIds, ['a', 'b']);
|
||||
});
|
||||
|
||||
Tinytest.add('minimongo - operation result fields (sync)', test => {
|
||||
const c = new LocalCollection();
|
||||
|
||||
// Test insert
|
||||
const insertedId = c.insert({name: 'doc1'});
|
||||
test.isTrue(insertedId !== undefined, 'insert should return an ID');
|
||||
|
||||
// Test update
|
||||
const updateResult = c.update({name: 'doc1'}, {$set: {value: 1}});
|
||||
test.equal(updateResult, 1, 'update should return affected count');
|
||||
|
||||
// Test upsert (update case)
|
||||
const upsertUpdateResult = c.upsert({name: 'doc1'}, {$set: {value: 2}});
|
||||
test.equal(upsertUpdateResult.numberAffected, 1);
|
||||
test.isFalse(upsertUpdateResult.hasOwnProperty('insertedId'));
|
||||
|
||||
// Test upsert (insert case)
|
||||
const upsertInsertResult = c.upsert({name: 'doc2'}, {$set: {value: 3}});
|
||||
test.equal(upsertInsertResult.numberAffected, 1);
|
||||
test.isTrue(upsertInsertResult.hasOwnProperty('insertedId'));
|
||||
|
||||
// Test remove
|
||||
const removeResult = c.remove({name: 'doc1'});
|
||||
test.equal(removeResult, 1, 'remove should return removed count');
|
||||
});
|
||||
|
||||
Tinytest.addAsync('minimongo - operation result fields (async)', async test => {
|
||||
const c = new LocalCollection();
|
||||
|
||||
// Test insert
|
||||
const insertedId = await c.insertAsync({name: 'doc1'});
|
||||
test.isTrue(insertedId !== undefined, 'insert should return an ID');
|
||||
|
||||
// Test update
|
||||
const updateResult = await c.updateAsync({name: 'doc1'}, {$set: {value: 1}});
|
||||
test.equal(updateResult, 1, 'update should return affected count');
|
||||
|
||||
// Test upsert (update case)
|
||||
const upsertUpdateResult = await c.upsertAsync({name: 'doc1'}, {$set: {value: 2}});
|
||||
test.equal(upsertUpdateResult.numberAffected, 1);
|
||||
test.isFalse(upsertUpdateResult.hasOwnProperty('insertedId'));
|
||||
|
||||
// Test upsert (insert case)
|
||||
const upsertInsertResult = await c.upsertAsync({name: 'doc2'}, {$set: {value: 3}});
|
||||
test.equal(upsertInsertResult.numberAffected, 1);
|
||||
test.isTrue(upsertInsertResult.hasOwnProperty('insertedId'));
|
||||
|
||||
// Test remove
|
||||
const removeResult = await c.removeAsync({name: 'doc1'});
|
||||
test.equal(removeResult, 1, 'remove should return removed count');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Package.describe({
|
||||
summary: "Meteor's client-side datastore: a port of MongoDB to Javascript",
|
||||
version: "2.0.3",
|
||||
version: "2.0.4",
|
||||
});
|
||||
|
||||
Package.onUse((api) => {
|
||||
|
||||
@@ -850,6 +850,7 @@ Object.assign(MongoConnection.prototype, {
|
||||
const oplogOptions = self?._oplogHandle?._oplogOptions || {};
|
||||
const { includeCollections, excludeCollections } = oplogOptions;
|
||||
if (firstHandle) {
|
||||
|
||||
var matcher, sorter;
|
||||
var canUseOplog = [
|
||||
function () {
|
||||
@@ -887,7 +888,7 @@ Object.assign(MongoConnection.prototype, {
|
||||
} catch (e) {
|
||||
// XXX make all compilation errors MinimongoError or something
|
||||
// so that this doesn't ignore unrelated exceptions
|
||||
if (e instanceof MiniMongoQueryError) {
|
||||
if (Meteor.isClient && e instanceof MiniMongoQueryError) {
|
||||
throw e;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { ObserveHandle } from './observe_handle';
|
||||
import isEmpty from "lodash.isempty";
|
||||
import { ObserveHandle } from "./observe_handle";
|
||||
|
||||
interface ObserveMultiplexerOptions {
|
||||
ordered: boolean;
|
||||
onStop?: () => void;
|
||||
}
|
||||
|
||||
export type ObserveHandleCallback = 'added' | 'addedBefore' | 'changed' | 'movedBefore' | 'removed';
|
||||
export type ObserveHandleCallback =
|
||||
| "added"
|
||||
| "addedBefore"
|
||||
| "changed"
|
||||
| "movedBefore"
|
||||
| "removed";
|
||||
|
||||
/**
|
||||
* Allows multiple identical ObserveHandles to be driven by a single observe driver.
|
||||
@@ -29,8 +34,12 @@ export class ObserveMultiplexer {
|
||||
if (ordered === undefined) throw Error("must specify ordered");
|
||||
|
||||
// @ts-ignore
|
||||
Package['facts-base'] && Package['facts-base']
|
||||
.Facts.incrementServerFact("mongo-livedata", "observe-multiplexers", 1);
|
||||
Package["facts-base"] &&
|
||||
Package["facts-base"].Facts.incrementServerFact(
|
||||
"mongo-livedata",
|
||||
"observe-multiplexers",
|
||||
1
|
||||
);
|
||||
|
||||
this._ordered = ordered;
|
||||
this._onStop = onStop;
|
||||
@@ -38,12 +47,14 @@ export class ObserveMultiplexer {
|
||||
this._handles = {};
|
||||
this._resolver = null;
|
||||
this._isReady = false;
|
||||
this._readyPromise = new Promise(r => this._resolver = r).then(() => this._isReady = true);
|
||||
this._readyPromise = new Promise((r) => (this._resolver = r)).then(
|
||||
() => (this._isReady = true)
|
||||
);
|
||||
// @ts-ignore
|
||||
this._cache = new LocalCollection._CachingChangeObserver({ ordered });
|
||||
this._addHandleTasksScheduledButNotPerformed = 0;
|
||||
|
||||
this.callbackNames().forEach(callbackName => {
|
||||
this.callbackNames().forEach((callbackName) => {
|
||||
(this as any)[callbackName] = (...args: any[]) => {
|
||||
this._applyCallback(callbackName, args);
|
||||
};
|
||||
@@ -58,14 +69,19 @@ export class ObserveMultiplexer {
|
||||
++this._addHandleTasksScheduledButNotPerformed;
|
||||
|
||||
// @ts-ignore
|
||||
Package['facts-base'] && Package['facts-base'].Facts.incrementServerFact(
|
||||
"mongo-livedata", "observe-handles", 1);
|
||||
Package["facts-base"] &&
|
||||
Package["facts-base"].Facts.incrementServerFact(
|
||||
"mongo-livedata",
|
||||
"observe-handles",
|
||||
1
|
||||
);
|
||||
|
||||
await this._queue.runTask(async () => {
|
||||
this._handles![handle._id] = handle;
|
||||
await this._sendAdds(handle);
|
||||
--this._addHandleTasksScheduledButNotPerformed;
|
||||
});
|
||||
|
||||
await this._readyPromise;
|
||||
}
|
||||
|
||||
@@ -76,11 +92,17 @@ export class ObserveMultiplexer {
|
||||
delete this._handles![id];
|
||||
|
||||
// @ts-ignore
|
||||
Package['facts-base'] && Package['facts-base'].Facts.incrementServerFact(
|
||||
"mongo-livedata", "observe-handles", -1);
|
||||
Package["facts-base"] &&
|
||||
Package["facts-base"].Facts.incrementServerFact(
|
||||
"mongo-livedata",
|
||||
"observe-handles",
|
||||
-1
|
||||
);
|
||||
|
||||
if (isEmpty(this._handles) &&
|
||||
this._addHandleTasksScheduledButNotPerformed === 0) {
|
||||
if (
|
||||
isEmpty(this._handles) &&
|
||||
this._addHandleTasksScheduledButNotPerformed === 0
|
||||
) {
|
||||
await this._stop();
|
||||
}
|
||||
}
|
||||
@@ -92,8 +114,12 @@ export class ObserveMultiplexer {
|
||||
await this._onStop();
|
||||
|
||||
// @ts-ignore
|
||||
Package['facts-base'] && Package['facts-base']
|
||||
.Facts.incrementServerFact("mongo-livedata", "observe-multiplexers", -1);
|
||||
Package["facts-base"] &&
|
||||
Package["facts-base"].Facts.incrementServerFact(
|
||||
"mongo-livedata",
|
||||
"observe-multiplexers",
|
||||
-1
|
||||
);
|
||||
|
||||
this._handles = null;
|
||||
}
|
||||
@@ -144,8 +170,11 @@ export class ObserveMultiplexer {
|
||||
if (!this._handles) return;
|
||||
|
||||
await this._cache.applyChange[callbackName].apply(null, args);
|
||||
if (!this._ready() &&
|
||||
(callbackName !== 'added' && callbackName !== 'addedBefore')) {
|
||||
if (
|
||||
!this._ready() &&
|
||||
callbackName !== "added" &&
|
||||
callbackName !== "addedBefore"
|
||||
) {
|
||||
throw new Error(`Got ${callbackName} during initial adds`);
|
||||
}
|
||||
|
||||
@@ -158,10 +187,20 @@ export class ObserveMultiplexer {
|
||||
|
||||
if (!callback) continue;
|
||||
|
||||
handle.initialAddsSent.then(callback.apply(
|
||||
const result = callback.apply(
|
||||
null,
|
||||
handle.nonMutatingCallbacks ? args : EJSON.clone(args)
|
||||
))
|
||||
);
|
||||
|
||||
if (result && Meteor._isPromise(result)) {
|
||||
result.catch((error) => {
|
||||
console.error(
|
||||
`Error in observeChanges callback ${callbackName}:`,
|
||||
error
|
||||
);
|
||||
});
|
||||
}
|
||||
handle.initialAddsSent.then(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -170,24 +209,38 @@ export class ObserveMultiplexer {
|
||||
const add = this._ordered ? handle._addedBefore : handle._added;
|
||||
if (!add) return;
|
||||
|
||||
const addPromises: Promise<void>[] = [];
|
||||
const addPromises: (Promise<void> | void)[] = [];
|
||||
|
||||
// note: docs may be an _IdMap or an OrderedDict
|
||||
this._cache.docs.forEach((doc: any, id: string) => {
|
||||
if (!(handle._id in this._handles!)) {
|
||||
throw Error("handle got removed before sending initial adds!");
|
||||
}
|
||||
|
||||
const { _id, ...fields } = handle.nonMutatingCallbacks ? doc : EJSON.clone(doc);
|
||||
const { _id, ...fields } = handle.nonMutatingCallbacks
|
||||
? doc
|
||||
: EJSON.clone(doc);
|
||||
|
||||
const promise = this._ordered ?
|
||||
add(id, fields, null) :
|
||||
add(id, fields);
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
try {
|
||||
const r = this._ordered ? add(id, fields, null) : add(id, fields);
|
||||
resolve(r);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
addPromises.push(promise);
|
||||
});
|
||||
|
||||
await Promise.all(addPromises);
|
||||
await Promise.allSettled(addPromises).then((p) => {
|
||||
p.forEach((result) => {
|
||||
if (result.status === "rejected") {
|
||||
console.error(`Error in adds for handle: ${result.reason}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
handle.initialAddsSentResolver();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
Package.describe({
|
||||
summary: "Adaptor for using MongoDB and Minimongo over DDP",
|
||||
version: "2.1.3",
|
||||
version: "2.1.4",
|
||||
});
|
||||
|
||||
Npm.depends({
|
||||
|
||||
@@ -4489,6 +4489,49 @@ testAsyncMulti(
|
||||
);
|
||||
|
||||
|
||||
Meteor.isServer && testAsyncMulti(
|
||||
"mongo-livedata - observeChangesAsync callback errors should not crash the process",
|
||||
[
|
||||
async (test) => {
|
||||
const Collection = new Mongo.Collection(
|
||||
`observe_changes_async_error_async_method${test.runId()}`,
|
||||
{ resolverType: 'stub' }
|
||||
);
|
||||
|
||||
let insertId;
|
||||
await Collection.find({}).observeChangesAsync({
|
||||
async added(_id, fields) {
|
||||
insertId = _id;
|
||||
throw new Error('Test error in async added observeChangesAsync');
|
||||
},
|
||||
});
|
||||
|
||||
return Collection.insertAsync({ foo: { bar: 123 } }).finally((id, bad) => {
|
||||
test.equal(insertId, id);
|
||||
})
|
||||
},
|
||||
|
||||
async (test) => {
|
||||
const Collection = new Mongo.Collection(
|
||||
`observe_changes_async_error_sync_method${test.runId()}`,
|
||||
{ resolverType: 'stub' }
|
||||
);
|
||||
|
||||
let insertId;
|
||||
await Collection.find({}).observeChangesAsync({
|
||||
added(id) {
|
||||
insertId = _id;
|
||||
throw new Error('Test error in sync added observeChangesAsync');
|
||||
},
|
||||
});
|
||||
|
||||
return Collection.insertAsync({ foo: { bar: 123 } }).finally((id, bad) => {
|
||||
test.equal(insertId, id);
|
||||
})
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
Meteor.methods({
|
||||
[`methodThrowException`]: async () => {
|
||||
if (Meteor.isClient) {
|
||||
@@ -4516,3 +4559,60 @@ Tinytest.addAsync(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const geoPolygonSchema = {
|
||||
type: 'Polygon',
|
||||
coordinates: Match.Where(coords =>
|
||||
Array.isArray(coords) &&
|
||||
coords.length > 0 &&
|
||||
coords.every(
|
||||
ring => Array.isArray(ring) && ring.every(
|
||||
point => Array.isArray(point) && point.length === 2 &&
|
||||
typeof point[0] === 'number' && typeof point[1] === 'number'
|
||||
)
|
||||
)
|
||||
)
|
||||
};
|
||||
|
||||
Tinytest.addAsync('mongo-livedata - publish with $geoIntersects returns correct docs', async function(test, onComplete) {
|
||||
if (Meteor.isServer) {
|
||||
const Features = new Mongo.Collection('Features_' + Random.id());
|
||||
const insidePoly = {
|
||||
_id: 'inside',
|
||||
hull: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[ [0.2,0.2], [0.2,0.8], [0.8,0.8], [0.8,0.2], [0.2,0.2] ]
|
||||
]
|
||||
}
|
||||
};
|
||||
const outsidePoly = {
|
||||
_id: 'outside',
|
||||
hull: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[ [2,2], [2,3], [3,3], [3,2], [2,2] ]
|
||||
]
|
||||
}
|
||||
};
|
||||
await Features.insertAsync(insidePoly);
|
||||
await Features.insertAsync(outsidePoly);
|
||||
|
||||
const viewport = {
|
||||
bounds: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[ [0,0], [0,1], [1,1], [1,0], [0,0] ]
|
||||
]
|
||||
}
|
||||
};
|
||||
const cursor = Features.find({ hull: { $geoIntersects: { $geometry: viewport.bounds } } });
|
||||
const docs = await cursor.fetchAsync();
|
||||
test.equal(docs.length, 1);
|
||||
test.equal(docs[0]._id, 'inside');
|
||||
onComplete();
|
||||
}
|
||||
if (Meteor.isClient) {
|
||||
onComplete();
|
||||
}
|
||||
});
|
||||
@@ -545,6 +545,7 @@ if (Meteor.isServer) {
|
||||
}
|
||||
|
||||
|
||||
// Those errors should throw since mongo doent support {$in: null}
|
||||
testAsyncMulti("observeChanges - bad query", [
|
||||
async function (test, expect) {
|
||||
var c = makeCollection();
|
||||
@@ -563,15 +564,9 @@ testAsyncMulti("observeChanges - bad query", [
|
||||
return;
|
||||
}
|
||||
|
||||
const p1 = new Promise(r => {
|
||||
observeThrows().finally(() => r());
|
||||
});
|
||||
const p2 = new Promise(r => {
|
||||
observeThrows().finally(() => r());
|
||||
});
|
||||
|
||||
await p1;
|
||||
await p2;
|
||||
await test.throwsAsync(async function () {
|
||||
await c.find({__id: {$in: null}}).countAsync();
|
||||
}, '$in needs an array');
|
||||
}
|
||||
]);
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Package.describe({
|
||||
summary: 'Compiler for CoffeeScript code, supporting the coffeescript package',
|
||||
// This version of NPM `coffeescript` module, with _1, _2 etc.
|
||||
// If you change this, make sure to also update ../coffeescript/package.js to match.
|
||||
version: '2.4.2',
|
||||
version: '2.4.3',
|
||||
});
|
||||
|
||||
Npm.depends({
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
Package.describe({
|
||||
summary: "Wrapper around the mongo npm package",
|
||||
version: "6.16.0",
|
||||
version: "6.16.1",
|
||||
documentation: null,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const { MongoClient, MongoCompatibilityError } = Npm.require('mongodb');
|
||||
const { MongoClient } = Npm.require('mongodb');
|
||||
|
||||
function connect(client) {
|
||||
return client.connect()
|
||||
.catch(error => {
|
||||
if (error.cause instanceof MongoCompatibilityError && error.message.includes('maximum wire version')) {
|
||||
// we just check the message since multiples errors can be catch this situation, e.g: instanceof MongoServerSelectionError or MongoCompatibilityError
|
||||
if (error.message.includes('maximum wire version')) {
|
||||
console.warn(`[DEPRECATION] Legacy MongoDB version detected, using mongo-legacy package: ${error.message}
|
||||
Warning: MongoDB versions <= 3.6 are deprecated. Some Meteor features may not work properly with this version.
|
||||
It is recommended to use MongoDB >= 4.`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Package.describe({
|
||||
name: "shell-server",
|
||||
version: '0.6.1',
|
||||
version: '0.6.2',
|
||||
summary: "Server-side component of the `meteor shell` command.",
|
||||
documentation: "README.md"
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Package.describe({
|
||||
name: 'typescript',
|
||||
version: '5.6.5',
|
||||
version: '5.6.6',
|
||||
summary:
|
||||
'Compiler plugin that compiles TypeScript and ECMAScript in .ts and .tsx files',
|
||||
documentation: 'README.md',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"track": "METEOR",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.2",
|
||||
"recommended": false,
|
||||
"official": true,
|
||||
"description": "The Official Meteor Distribution"
|
||||
|
||||
@@ -72,10 +72,6 @@ export default defineConfig({
|
||||
text: "Packages on Atmosphere",
|
||||
link: "https://atmospherejs.com/",
|
||||
},
|
||||
{
|
||||
text: "VS Code Extension",
|
||||
link: "https://marketplace.visualstudio.com/items?itemName=meteor-toolbox.meteor-toolbox",
|
||||
},
|
||||
{
|
||||
text: "DevTools - Chrome Extension",
|
||||
link: "https://chromewebstore.google.com/detail/ibniinmoafhgbifjojidlagmggecmpgf",
|
||||
|
||||
@@ -279,3 +279,19 @@ After building your Cordova project with Meteor, you can use **Android Studio**
|
||||
5. Go to **Product > Archive** to create an archive of your app
|
||||
6. In the **Organizer** window, click **Distribute App** and follow the prompts to configure signing and export the IPA file.
|
||||
7. Upload the IPA file to the App Store or distribute via TestFlight.
|
||||
|
||||
# Legacy device support
|
||||
|
||||
Meteor distinguishes between legacy and modern browsers - see the [modern browsers package](../packages/modern-browsers). Web apps include different code bundles for each, but Cordova apps only have a single code bundle. From Meteor 3.3.2 onwards, the default code bundle changed from legacy to modern.
|
||||
|
||||
You can force Meteor to use the legacy browser code bundle by setting the variable `cordova.disableModern` to `true` in `package.json` when running or building your app. For example:
|
||||
|
||||
```
|
||||
"meteor": {
|
||||
"mainModule": { ... },
|
||||
"testModule": { ... },
|
||||
"cordova": { "disableModern": true}
|
||||
}
|
||||
```
|
||||
|
||||
Both the App Store and Google Play will only publish new and updated apps for a certain minimum mobile OS version. As of 2025, these minimum OS versions support the modern browser code bundle.
|
||||
|
||||
@@ -39,8 +39,6 @@ Meteor is a full-stack JavaScript platform for developing modern web and mobile
|
||||
|
||||
- Explore and contribute to our [GitHub repository](https://github.com/meteor). You can access our code, request new features, and start contributing.
|
||||
|
||||
- Enhance your coding experience with the [VS Code Extension](https://marketplace.visualstudio.com/items?itemName=meteor-toolbox.meteor-toolbox), which provides IntelliSense support for Meteor's core and packages.
|
||||
|
||||
- Use the [Chrome Extension](https://chrome.google.com/webstore/detail/meteor-devtools-evolved/ibniinmoafhgbifjojidlagmggecmpgf) or [Firefox Extension](https://addons.mozilla.org/en-US/firefox/addon/meteor-devtools-evolved/) for insights into your app's performance and to understand what is going on under the hood of your application.
|
||||
|
||||
- Discover [Meteor Examples](https://github.com/meteor/examples) to see a range of projects built with Meteor.
|
||||
|
||||
69
v3-docs/docs/generators/changelog/versions/3.3.2.md
Normal file
69
v3-docs/docs/generators/changelog/versions/3.3.2.md
Normal file
@@ -0,0 +1,69 @@
|
||||
## v3.3.2, 01-09-2025
|
||||
|
||||
### Highlights
|
||||
|
||||
- Async-compatible account URLs and email-sending coverage [#13740](https://github.com/meteor/meteor/pull/13740)
|
||||
- Move `findUserByEmail` method from `accounts-password` to `accounts-base` [#13859](https://github.com/meteor/meteor/pull/13859)
|
||||
- Return `insertedId` on client `upsert` to match Meteor 2.x behavior [#13891](https://github.com/meteor/meteor/pull/13891)
|
||||
- Unrecognized operator bug fixed [#13895](https://github.com/meteor/meteor/pull/13895)
|
||||
- Security fix for `sha.js` [#13908](https://github.com/meteor/meteor/pull/13908)
|
||||
|
||||
|
||||
All Merged PRs@[GitHub PRs 3.3.2](https://github.com/meteor/meteor/pulls?q=is%3Apr+is%3Amerged+base%3Arelease-3.3.2)
|
||||
|
||||
#### Breaking Changes
|
||||
|
||||
N/A
|
||||
|
||||
##### Cordova Upgrade
|
||||
|
||||
- Enable modern browser support for Cordova unless explicitly disabled [#13896](https://github.com/meteor/meteor/pull/13896)
|
||||
|
||||
#### Internal API changes
|
||||
|
||||
- lodash.template dependency was removed [#13898](https://github.com/meteor/meteor/pull/13898)
|
||||
|
||||
#### Migration Steps
|
||||
|
||||
Please run the following command to update your project:
|
||||
|
||||
```bash
|
||||
meteor update --release 3.3.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
If you find any issues, please report them to the [Meteor issues tracker](https://github.com/meteor/meteor).
|
||||
|
||||
#### Bumped Meteor Packages
|
||||
|
||||
- accounts-base@3.1.2
|
||||
- accounts-password@3.2.1
|
||||
- accounts-passwordless@3.0.2
|
||||
- meteor-node-stubs@1.2.24
|
||||
- babel-compiler@7.12.2
|
||||
- boilerplate-generator@2.0.2
|
||||
- ecmascript@0.16.13
|
||||
- minifier@3.0.4
|
||||
- minimongo@2.0.4
|
||||
- mongo@2.1.4
|
||||
- coffeescript-compiler@2.4.3
|
||||
- npm-mongo@6.16.1
|
||||
- shell-server@0.6.2
|
||||
- typescript@5.6.6
|
||||
|
||||
#### Bumped NPM Packages
|
||||
|
||||
- meteor-node-stubs@1.2.23
|
||||
|
||||
#### Special thanks to
|
||||
|
||||
✨✨✨
|
||||
|
||||
- [@italojs](https://github.com/italojs)
|
||||
- [@nachocodoner](https://github.com/nachocodoner)
|
||||
- [@graemian](https://github.com/graemian)
|
||||
- [@Grubba27](https://github.com/Grubba27)
|
||||
- [@copleykj](https://github.com/copleykj)
|
||||
|
||||
✨✨✨
|
||||
@@ -4,7 +4,7 @@ In this tutorial, we will create a simple To-Do app using [React](https://react.
|
||||
|
||||
React is a popular JavaScript library for building user interfaces. It allows you to create dynamic and interactive applications by composing UI components. React uses a declarative approach, where you define how the UI should look based on the state, and it efficiently updates the view when the state changes. With JSX, a syntax extension that combines JavaScript and HTML, React makes it easy to create reusable components that manage their own state and render seamlessly in the browser.
|
||||
|
||||
To start building your React app, you'll need a code editor. If you're unsure which one to choose, [Visual Studio Code](https://code.visualstudio.com/) is a good option. After installing it, you can enhance your experience by adding extensions like [Meteor Toolbox](https://marketplace.visualstudio.com/items?itemName=meteor-toolbox.meteor-toolbox).
|
||||
To start building your React app, you'll need a code editor. If you're unsure which one to choose, [Visual Studio Code](https://code.visualstudio.com/) is a good option.
|
||||
|
||||
Let’s begin building your app!
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ In this tutorial, we will create a simple To-Do app using [Vue 3](https://vuejs.
|
||||
|
||||
Vue.js is a powerful JavaScript framework for making user interfaces. It helps you build interactive applications by using templates that connect to data and update automatically when the data changes. Vue.js templates use a simple syntax similar to HTML and work with Vue’s reactivity system to show components in the browser.
|
||||
|
||||
To start building your Vue.js app, you'll need a code editor. If you're unsure which one to choose, [Visual Studio Code](https://code.visualstudio.com/) is a good option. After installing it, you can enhance your experience by adding extensions like [Meteor Toolbox](https://marketplace.visualstudio.com/items?itemName=meteor-toolbox.meteor-toolbox) and [Vue Language Features](https://marketplace.visualstudio.com/items?itemName=Vue.volar).
|
||||
|
||||
To start building your Vue.js app, you'll need a code editor. If you're unsure which one to choose, [Visual Studio Code](https://code.visualstudio.com/) is a good option.
|
||||
:::warning
|
||||
This tutorial uses the `vue-meteor-tracker` package, which is currently in beta and does not support async calls yet. However, it is still a valuable package, and we hope it will be updated soon. We are also working on a new tutorial that will use Meteor core packages instead.
|
||||
:::
|
||||
@@ -206,7 +205,7 @@ Before creating our collection, let's remove the `links.js` file from the `impor
|
||||
::: code-group
|
||||
```javascript [imports/api/tasksCollection.js]
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
|
||||
|
||||
export const TasksCollection = new Mongo.Collection('tasks');
|
||||
```
|
||||
:::
|
||||
@@ -259,7 +258,7 @@ Meteor works with Meteor packages and NPM packages, usually Meteor packages are
|
||||
|
||||
The `vue-meteor-tracker` package is already included in the Vue skeleton, so you don’t need to add it.
|
||||
|
||||
When importing code from a Meteor package the only difference from NPM modules is that you need to prepend `meteor/` in the from part of your import.
|
||||
When importing code from a Meteor package the only difference from NPM modules is that you need to prepend `meteor/` in the from part of your import.
|
||||
First we need to implement a subscription at the `App` component to get the tasks updated from the server. It can be done simply by using the `subscribe` and `autorun` functions from `vue-meteor-tracker`.
|
||||
|
||||
::: info
|
||||
@@ -500,7 +499,7 @@ Until now, you have only inserted documents to our collection. Let’s see how y
|
||||
### 4.1: Add Checkbox
|
||||
|
||||
|
||||
First, you need to add a `checkbox` element to your `Task` component, and we need to add the `v-model` directive to the checkbox. This will allow us to bind the value of the checkbox to the `checked` field of the task document.
|
||||
First, you need to add a `checkbox` element to your `Task` component, and we need to add the `v-model` directive to the checkbox. This will allow us to bind the value of the checkbox to the `checked` field of the task document.
|
||||
To do this, we need to add a `ref` to the task document. This will allow us to access the task document in the template. And add a computed property `isChecked` for the state management of the checkbox.
|
||||
|
||||
We also have a prop called `task` that is passed to the component. This prop is an object that represents the task document.
|
||||
@@ -604,7 +603,7 @@ const isChecked = computed(() => taskRef.value.checked);
|
||||
const handleCheckboxChange = async (event) => {
|
||||
const newCheckedValue = event.target.checked;
|
||||
taskRef.value.checked = newCheckedValue;
|
||||
|
||||
|
||||
try {
|
||||
await Meteor.callAsync('setIsCheckedTask', taskRef.value._id, newCheckedValue);
|
||||
} catch (error) {
|
||||
@@ -644,9 +643,9 @@ First add a button after the text in your `Task` component and receive a callbac
|
||||
{{ task.text }}
|
||||
</span>
|
||||
|
||||
<button
|
||||
<button
|
||||
class="ml-auto bg-red-500 hover:bg-red-600 text-white font-bold py-0.5 px-2 rounded"
|
||||
@click="deleteTask"> ×
|
||||
@click="deleteTask"> ×
|
||||
</button>
|
||||
...
|
||||
```
|
||||
@@ -829,7 +828,7 @@ You should avoid adding zero to your app bar when there are no pending tasks.
|
||||
::: code-group
|
||||
```vue [imports/ui/App.vue]
|
||||
<script setup>
|
||||
...
|
||||
...
|
||||
const incompleteTasksCount = autorun(() => {
|
||||
return TasksCollection.find({ checked: { $ne: true } }).count();
|
||||
}).result;
|
||||
@@ -1235,7 +1234,7 @@ async function setIsCheckedTask(taskId, checked) {
|
||||
if (!Meteor.userId()) {
|
||||
throw new Meteor.Error('Not authorized.');
|
||||
}
|
||||
|
||||
|
||||
await TasksCollection.updateAsync(taskId, {
|
||||
$set: {
|
||||
checked
|
||||
@@ -1329,13 +1328,13 @@ Make sure you replace `vue3-meteor-3` by a custom name that you want as subdomai
|
||||
```shell
|
||||
meteor deploy vue3-meteor-3.meteorapp.com --settings private/settings.json
|
||||
Talking to Galaxy servers at https://us-east-1.galaxy-deploy.meteor.com
|
||||
Preparing to build your app...
|
||||
Preparing to upload your app...
|
||||
Preparing to build your app...
|
||||
Preparing to upload your app...
|
||||
Uploaded app bundle for new app at vue-tutorial.meteorapp.com.
|
||||
Galaxy is building the app into a native image.
|
||||
Waiting for deployment updates from Galaxy...
|
||||
Building app image...
|
||||
Deploying app...
|
||||
Waiting for deployment updates from Galaxy...
|
||||
Building app image...
|
||||
Deploying app...
|
||||
You have successfully deployed the first version of your app.
|
||||
For details, visit https://galaxy.meteor.com/app/vue3-meteor-3.meteorapp.com
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user