Compare commits

...

73 Commits

Author SHA1 Message Date
renovate[bot]
e771a02d7d chore(deps): update dependency husky to v9 2025-12-13 15:38:05 +00:00
renovate[bot]
ad895efb61 chore(deps): update dependency smtp-server to v3.17.1 (#13829)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 10:36:23 -05:00
renovate[bot]
22fe83f005 chore(deps): update dependency @eslint/js to v9.39.2 (#13830)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 10:36:13 -05:00
renovate[bot]
b169621860 chore(deps): update github artifact actions (#13831)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 10:36:02 -05:00
renovate[bot]
da7c9b32b8 fix(deps): update dependency terser-webpack-plugin to v5.3.16 (#13827)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 07:56:00 -05:00
renovate[bot]
0fcc8543c6 chore(deps): update actions/cache action to v5 (#13828)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-12 07:55:41 -05:00
Barış Soner Uşaklı
ed977c48b4 Merge branch 'master' into develop 2025-12-11 21:25:56 -05:00
Barış Soner Uşaklı
f49f540bfa fix: show errors when saving settings 2025-12-11 21:25:42 -05:00
Julian Lam
20918b5281 fix: wrong increment value 2025-12-11 11:10:29 -05:00
Julian Lam
8abe0dfa9f fix: increment progress on upgrade script 2025-12-11 11:09:13 -05:00
Julian Lam
097d0802b7 feat: stop extraneous vote and tids_read data from being saved for remote users 2025-12-11 10:57:00 -05:00
renovate[bot]
3adcbe0f7d chore(deps): update dependency smtp-server to v3.17.0 (#13824)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:34:57 -05:00
renovate[bot]
b992511bb9 chore(deps): update dependency sass-embedded to v1.96.0 (#13821)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:13:51 -05:00
renovate[bot]
d4f53a6242 fix(deps): update dependency sass to v1.96.0 (#13822)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 10:13:35 -05:00
Julian Lam
528cd258c4 feat: support remote Dislike activity, federate out a Dislike on downvote, bwahahah 2025-12-10 12:40:46 -05:00
renovate[bot]
a2f2c8c761 chore(deps): update dependency sass-embedded to v1.95.1 (#13817)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 20:04:25 -05:00
renovate[bot]
81c232f181 fix(deps): update dependency winston to v3.19.0 (#13812)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 19:47:59 -05:00
renovate[bot]
f077c4cab8 fix(deps): update dependency cron to v4.4.0 (#13818)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 19:46:55 -05:00
renovate[bot]
adedb7b626 fix(deps): update dependency sass to v1.95.1 (#13816)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 19:46:11 -05:00
renovate[bot]
a35c326a6c chore(deps): update dependency jsdom to v27.3.0 (#13814)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 19:25:06 -05:00
renovate[bot]
eaa6e71a99 fix(deps): update dependency sass to v1.95.0 (#13815)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-09 19:24:47 -05:00
Barış Soner Uşaklı
011f8b2465 Merge branch 'master' into develop 2025-12-08 10:19:00 -05:00
Barış Soner Uşaklı
b19281b061 revert: spec change 2025-12-08 10:18:38 -05:00
Barış Soner Uşaklı
9d6665505e chore: up widget-essentials 2025-12-08 09:43:50 -05:00
Barış Soner Uşaklı
2c57cb13e4 Merge branch 'master' into develop 2025-12-06 20:44:57 -05:00
Barış Soner Uşaklı
11b01dfccb test: fix tests 2025-12-06 20:44:51 -05:00
Barış Soner Uşaklı
f6fbb0226b Merge branch 'master' into develop 2025-12-06 20:40:30 -05:00
Konrad Moskal
2e00c0ff42 Modify delete post diff response format (#13761)
* Modify delete post diff response format

Updated the delete operation response to return JSON content.

* fix: timestamp open api schema
2025-12-06 20:12:27 -05:00
Barış Soner Uşaklı
193aaf55d5 fix: closes #13666, update category label
on topic move if we are not on category page
2025-12-06 20:08:05 -05:00
Barış Soner Uşaklı
823c6cb340 Merge branch 'master' into develop 2025-12-05 12:28:29 -05:00
Nephilim
ebf2a2c5af fix: respect user pagination settings in infinite scroll (#13765) (#13788)
- Changed hardcoded topicsPerPage value of 20 to use settings.topicsPerPage
- Allows infinite scroll to respect user's configured page size preference
- Consistent with pagination handling in other controllers (category.js, recent.js, etc)
- Validates against admin's maxTopicsPerPage setting
- Fixes issue where all users were limited to 20 topics per request regardless of settings
2025-12-05 12:26:53 -05:00
renovate[bot]
e50edd52fc chore(deps): update commitlint monorepo to v20.2.0 (#13810)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-05 12:24:16 -05:00
renovate[bot]
10d2e929a1 fix(deps): update dependency terser-webpack-plugin to v5.3.15 (#13811)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-05 12:24:04 -05:00
Barış Uşaklı
6956270411 test: add a test for set db.exists (#13809)
* test: add a test for set db.exists

* delete empty sets

* test: psql
2025-12-04 18:33:55 -05:00
Jakub Bliźniuk
254370c5be ci: drop ARM v7 from docker builds (#13808)
Removed optional ARM v7 (32 bit) platform from the workflow due to lack of support from Node and very limited usefulness.

As the platform had been flaky in the past, this part of the workflow was already optional and didn't cause it to fail. So this is just the next step here.
2025-12-04 18:11:04 -05:00
Julian Lam
c529244229 test: fix failing test by adjusting the tests 2025-12-04 16:03:28 -05:00
Julian Lam
f1d50c3510 fix: add join-lemmy context for outgoing category group actors context prop 2025-12-04 11:55:00 -05:00
renovate[bot]
6b1dcb4b90 fix(deps): update dependency esbuild to v0.27.1 (#13806)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 11:39:32 -05:00
renovate[bot]
7b734cfdc5 fix(deps): update dependency jsonwebtoken to v9.0.3 (#13807)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 11:37:57 -05:00
Julian Lam
6a56105037 feat: expand postingRestrictedToMods mask testing, handle actor update for that prop 2025-12-04 11:00:41 -05:00
Barış Uşaklı
7d5402fe66 feat: setAddBulk (#13805)
* feat: setAddBulk

add some tests

* fix: sAdd with value array on redis
2025-12-03 18:18:14 -05:00
Julian Lam
d8e55d58de fix: use setsAdd 2025-12-03 15:21:49 -05:00
Julian Lam
4a6dcf1a21 fix: missing await 2025-12-03 15:21:49 -05:00
Julian Lam
2968772287 chore: allow direct testing in test/categories.js 2025-12-03 15:21:49 -05:00
Julian Lam
934e6be911 test: privilege masking tests 2025-12-03 15:21:49 -05:00
Julian Lam
f0a7a442db feat: save privilege masking set when asserting group 2025-12-03 15:21:49 -05:00
Julian Lam
7b194c6916 fix: admin privilege overrides only apply to local categories 2025-12-03 15:21:49 -05:00
Julian Lam
4020e1be35 feat: patch low-level privilege query calls to accept privilege masks at the cid level 2025-12-03 15:21:49 -05:00
renovate[bot]
76b6b3b259 chore(deps): update dependency lint-staged to v16.2.7 (#13785)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 10:14:33 -05:00
Barış Soner Uşaklı
4cdb56904c Merge branch 'master' into develop 2025-12-03 10:14:13 -05:00
Barış Soner Uşaklı
9fb41c6933 lint: fix missing comma 2025-12-03 10:14:08 -05:00
Barış Soner Uşaklı
70169758ec Merge branch 'master' into develop 2025-12-03 09:49:22 -05:00
Barış Soner Uşaklı
ba85474dfb feat: add hreflang to buildLinkTag 2025-12-03 09:49:16 -05:00
renovate[bot]
7f21a17175 chore(deps): update actions/checkout action to v6 (#13802)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 14:41:12 -05:00
renovate[bot]
93057306f4 fix(deps): update dependency ace-builds to v1.43.5 (#13797)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 14:36:08 -05:00
renovate[bot]
731933a66b fix(deps): update dependency lru-cache to v11.2.4 (#13798)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 14:35:46 -05:00
renovate[bot]
38321220f2 fix(deps): update dependency express to v4.22.1 (#13800)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 14:35:32 -05:00
renovate[bot]
ad5cd27b37 fix(deps): update dependency ipaddr.js to v2.3.0 (#13801)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 14:27:07 -05:00
renovate[bot]
ecec1f4594 fix(deps): update dependency nodemailer to v7.0.11 (#13799)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 14:26:50 -05:00
Julian Lam
3b7bcba6c0 fix: have notes.assert call out.announce.topic only if uid is set (so, if note assertion is called via search; manual pull) 2025-12-02 14:15:13 -05:00
Julian Lam
a82e1f441c debug: still broken... more debug logs 2025-12-02 13:50:50 -05:00
Julian Lam
977a67f4cd fix: deep clone activity prop before execution; feps.announce 2025-12-02 13:42:06 -05:00
Julian Lam
8236b594af debug: log mock results 2025-12-02 13:19:48 -05:00
Barış Soner Uşaklı
22d3c52332 test: log label 2025-12-02 12:59:28 -05:00
Barış Soner Uşaklı
e39c91497f test: log activities 2025-12-02 12:47:47 -05:00
Barış Soner Uşaklı
841bd8252c test: on test fail show activities 2025-12-02 11:54:14 -05:00
renovate[bot]
5ba6bea049 fix(deps): update dependency cron to v4.3.5 (#13796)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 11:44:46 -05:00
renovate[bot]
624ef61655 fix(deps): update dependency body-parser to v2.2.1 (#13795)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 11:44:29 -05:00
Barış Soner Uşaklı
53e22acffb fix: remove hardcoded name for sentinel, #13794 2025-12-02 11:12:05 -05:00
Barış Uşaklı
287b25695d test: new mongodb deps (#13793) 2025-12-02 11:08:20 -05:00
renovate[bot]
5f55ca85e6 fix(deps): update dependency @isaacs/ttlcache to v2.1.3 (#13791)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 09:50:11 -05:00
Barış Soner Uşaklı
2142b680d9 chore: remove log 2025-12-01 17:47:46 -05:00
Barış Soner Uşaklı
5bd1f7b7ac feat: #13790, allow ssl setup in psql 2025-12-01 17:46:01 -05:00
32 changed files with 663 additions and 136 deletions

View File

@@ -26,9 +26,6 @@ jobs:
- os: ubuntu-24.04-arm
platforms: linux/arm64
required: true
- os: ubuntu-24.04-arm
platforms: linux/arm/v7
required: false
continue-on-error: ${{ !matrix.required }}
runs-on: ${{ matrix.os }}
steps:
@@ -37,7 +34,7 @@ jobs:
platform=${{ matrix.platforms }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY@L}" >> $GITHUB_ENV
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -56,7 +53,7 @@ jobs:
- name: Cache node_modules
id: cache-node-modules
uses: actions/cache@v4
uses: actions/cache@v5
with:
path: var-cache-node-modules
key: var-cache-node-modules-${{ hashFiles('Dockerfile', 'install/package.json') }}
@@ -80,7 +77,7 @@ jobs:
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v6
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: ${{ runner.temp }}/digests/*
@@ -96,7 +93,7 @@ jobs:
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY@L}" >> $GITHUB_ENV
echo "CURRENT_DATE_NST=$(date +'%Y%m%d-%H%M%S' -d '-3 hours -30 minutes')" >> $GITHUB_ENV
- name: Download digests
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
@@ -131,4 +128,4 @@ jobs:
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: |
docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}
docker buildx imagetools inspect ${{ env.IMAGE }}:${{ steps.meta.outputs.version }}

View File

@@ -81,7 +81,7 @@ jobs:
- 27017:27017
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- run: cp install/package.json package.json

View File

@@ -33,19 +33,19 @@
"@fontsource/inter": "5.2.8",
"@fontsource/poppins": "5.2.7",
"@fortawesome/fontawesome-free": "6.7.2",
"@isaacs/ttlcache": "2.1.2",
"@isaacs/ttlcache": "2.1.3",
"@nodebb/spider-detector": "2.0.3",
"@popperjs/core": "2.11.8",
"@textcomplete/contenteditable": "0.1.13",
"@textcomplete/core": "0.1.13",
"@textcomplete/textarea": "0.1.13",
"ace-builds": "1.43.4",
"ace-builds": "1.43.5",
"archiver": "7.0.1",
"async": "3.2.6",
"autoprefixer": "10.4.22",
"bcryptjs": "3.0.3",
"benchpressjs": "2.5.5",
"body-parser": "2.2.0",
"body-parser": "2.2.1",
"bootbox": "6.0.4",
"bootstrap": "5.3.8",
"bootswatch": "5.3.8",
@@ -57,17 +57,17 @@
"compare-versions": "6.1.1",
"compression": "1.8.1",
"connect-flash": "0.1.1",
"connect-mongo": "5.1.0",
"connect-mongo": "6.0.0",
"connect-pg-simple": "10.0.0",
"connect-redis": "9.0.0",
"cookie-parser": "1.4.7",
"cron": "4.3.4",
"cron": "4.4.0",
"cropperjs": "1.6.2",
"csrf-sync": "4.2.1",
"daemon": "1.1.0",
"diff": "8.0.2",
"esbuild": "0.27.0",
"express": "4.21.2",
"esbuild": "0.27.1",
"express": "4.22.1",
"express-session": "1.18.2",
"express-useragent": "2.0.2",
"fetch-cookie": "3.1.0",
@@ -77,7 +77,7 @@
"helmet": "7.2.0",
"html-to-text": "9.0.5",
"imagesloaded": "5.0.0",
"ipaddr.js": "2.2.0",
"ipaddr.js": "2.3.0",
"jquery": "3.7.1",
"jquery-deserialize": "2.0.0",
"jquery-form": "4.3.0",
@@ -85,13 +85,13 @@
"jquery-ui": "1.14.1",
"jsesc": "3.1.0",
"json2csv": "5.0.7",
"jsonwebtoken": "9.0.2",
"jsonwebtoken": "9.0.3",
"lodash": "4.17.21",
"logrotate-stream": "0.2.9",
"lru-cache": "11.2.2",
"lru-cache": "11.2.4",
"mime": "3.0.0",
"mkdirp": "3.0.1",
"mongodb": "6.21.0",
"mongodb": "7.0.0",
"morgan": "1.10.1",
"mousetrap": "1.6.5",
"multer": "2.0.2",
@@ -111,8 +111,8 @@
"nodebb-theme-lavender": "7.1.19",
"nodebb-theme-peace": "2.2.49",
"nodebb-theme-persona": "14.1.18",
"nodebb-widget-essentials": "7.0.40",
"nodemailer": "7.0.10",
"nodebb-widget-essentials": "7.0.41",
"nodemailer": "7.0.11",
"nprogress": "0.2.0",
"passport": "0.7.0",
"passport-http-bearer": "1.0.1",
@@ -129,7 +129,7 @@
"rss": "1.2.2",
"rtlcss": "4.3.0",
"sanitize-html": "2.17.0",
"sass": "1.94.2",
"sass": "1.96.0",
"satori": "0.18.3",
"sbd": "^1.0.19",
"semver": "7.7.3",
@@ -141,7 +141,7 @@
"@socket.io/redis-adapter": "8.3.0",
"sortablejs": "1.15.6",
"spdx-license-list": "6.10.0",
"terser-webpack-plugin": "5.3.14",
"terser-webpack-plugin": "5.3.16",
"textcomplete": "0.18.2",
"textcomplete.contenteditable": "0.1.1",
"timeago": "1.6.7",
@@ -152,7 +152,7 @@
"validator": "13.15.23",
"webpack": "5.103.0",
"webpack-merge": "6.0.1",
"winston": "3.18.3",
"winston": "3.19.0",
"workerpool": "10.0.1",
"xml": "1.0.1",
"xregexp": "5.1.2",
@@ -161,26 +161,26 @@
},
"devDependencies": {
"@apidevtools/swagger-parser": "10.1.0",
"@commitlint/cli": "20.1.0",
"@commitlint/config-angular": "20.0.0",
"@commitlint/cli": "20.2.0",
"@commitlint/config-angular": "20.2.0",
"coveralls": "3.1.1",
"@eslint/js": "9.39.1",
"@eslint/js": "9.39.2",
"@stylistic/eslint-plugin": "5.6.1",
"eslint-config-nodebb": "1.1.11",
"eslint-plugin-import": "2.32.0",
"grunt": "1.6.1",
"grunt-contrib-watch": "1.1.0",
"husky": "8.0.3",
"jsdom": "27.2.0",
"lint-staged": "16.2.6",
"husky": "9.1.7",
"jsdom": "27.3.0",
"lint-staged": "16.2.7",
"mocha": "11.7.5",
"mocha-lcov-reporter": "1.3.0",
"mockdate": "3.0.5",
"nyc": "17.1.0",
"smtp-server": "3.16.1"
"smtp-server": "3.17.1"
},
"optionalDependencies": {
"sass-embedded": "1.93.3"
"sass-embedded": "1.96.0"
},
"resolutions": {
"*/jquery": "3.7.1"

View File

@@ -102,7 +102,7 @@ define('forum/account/settings', [
if (languageChanged && parseInt(app.user.uid, 10) === parseInt(ajaxify.data.theirid, 10)) {
window.location.reload();
}
});
}).catch(alerts.error);
}
function toggleCustomRoute() {

View File

@@ -284,8 +284,20 @@ define('forum/category/tools', [
topic.find('[component="topic/locked"]').toggleClass('hidden', !data.isLocked);
}
function onTopicMoved(data) {
getTopicEl(data.tid).remove();
async function onTopicMoved(data) {
if (ajaxify.data.template.category) {
getTopicEl(data.tid).remove();
} else {
const category = await api.get(`/categories/${data.toCid}`);
const html = await app.parseAndTranslate('partials/topics_list', {
topics: [{
...data,
category,
}],
});
const categoryLabelSelector = `[component="category/topic"][data-tid="${data.tid}"] [component="topic/category"]`;
$(categoryLabelSelector).replaceWith(html.find(categoryLabelSelector));
}
}
function onTopicPurged(data) {

View File

@@ -77,10 +77,12 @@ module.exports = function (utils, Benchpress, relative_path) {
}
function buildLinkTag(tag) {
const attributes = ['link', 'rel', 'as', 'type', 'href', 'sizes', 'title', 'crossorigin'];
const [link, rel, as, type, href, sizes, title, crossorigin] = attributes.map(attr => (tag[attr] ? `${attr}="${tag[attr]}" ` : ''));
const attributes = [
'link', 'rel', 'as', 'type', 'href', 'hreflang', 'sizes', 'title', 'crossorigin',
];
const [link, rel, as, type, href, hreflang, sizes, title, crossorigin] = attributes.map(attr => (tag[attr] ? `${attr}="${tag[attr]}" ` : ''));
return '<link ' + link + rel + as + type + sizes + title + href + crossorigin + '/>\n\t';
return '<link ' + link + rel + as + type + sizes + title + href + hreflang + crossorigin + '/>\n\t';
}
function stringify(obj) {
@@ -111,7 +113,7 @@ module.exports = function (utils, Benchpress, relative_path) {
}
const href = tag === 'a' ? `href="${relative_path}/category/${category.slug}"` : '';
return `<${tag} ${href} class="badge px-1 text-truncate text-decoration-none ${className}" style="color: ${category.color};background-color: ${category.bgColor};border-color: ${category.bgColor}!important; max-width: 70vw;">
return `<${tag} component="topic/category" ${href} class="badge px-1 text-truncate text-decoration-none ${className}" style="color: ${category.color};background-color: ${category.bgColor};border-color: ${category.bgColor}!important; max-width: 70vw;">
${category.icon && category.icon !== 'fa-nbb-none' ? `<i class="fa fa-fw ${category.icon}"></i>` : ''}
${category.name}
</${tag}>`;

View File

@@ -400,12 +400,20 @@ Actors.assertGroup = async (ids, options = {}) => {
db.deleteObjectFields('handle:cid', queries.handleRemove),
]);
// Privilege mask
const [masksAdd, masksRemove] = categoryObjs.reduce(([add, remove], category) => {
(category?._activitypub?.postingRestrictedToMods ? add : remove).push(`cid:${category.cid}:privilegeMask`);
return [add, remove];
}, [[], []]);
await Promise.all([
db.setObjectBulk(bulkSet),
db.sortedSetAdd('usersRemote:lastCrawled', groups.map(() => now), groups.map(p => p.id)),
db.sortedSetAddBulk(queries.searchAdd),
db.setObject('handle:cid', queries.handleAdd),
_migratePersonToGroup(categoryObjs),
db.setsAdd(masksAdd, 'topics:create'),
db.setsRemove(masksRemove, 'topics:create'),
]);
return categoryObjs;

View File

@@ -352,6 +352,26 @@ inbox.like = async (req) => {
socketHelpers.upvote(result, 'notifications:upvoted-your-post-in');
};
inbox.dislike = async (req) => {
const { actor, object } = req.body;
const { type, id } = await activitypub.helpers.resolveLocalId(object.id);
if (type !== 'post' || !(await posts.exists(id))) {
return reject('Dislike', object, actor);
}
const allowed = await privileges.posts.can('posts:downvote', id, activitypub._constants.uid);
if (!allowed) {
activitypub.helpers.log(`[activitypub/inbox.like] ${id} not allowed to be downvoted.`);
return reject('Dislike', object, actor);
}
activitypub.helpers.log(`[activitypub/inbox/dislike] id ${id} via ${actor}`);
await posts.downvote(id, actor);
await activitypub.feps.announce(object.id, req.body);
};
inbox.announce = async (req) => {
let { actor, object, published, to, cc } = req.body;
activitypub.helpers.log(`[activitypub/inbox/announce] Parsing Announce(${object.type}) from ${actor}`);

View File

@@ -281,6 +281,7 @@ Mocks.category = async (actors) => {
let {
url, preferredUsername, icon, /* image, */
name, summary, followers, inbox, endpoints, tag,
postingRestrictedToMods,
} = actor;
preferredUsername = slugify(preferredUsername || name);
/*
@@ -338,6 +339,10 @@ Mocks.category = async (actors) => {
inbox,
sharedInbox: endpoints ? endpoints.sharedInbox : null,
followersUrl: followers,
_activitypub: {
postingRestrictedToMods,
},
};
return payload;
@@ -561,6 +566,7 @@ Mocks.actors.category = async (cid) => {
'@context': [
'https://www.w3.org/ns/activitystreams',
'https://w3id.org/security/v1',
'https://join-lemmy.org/context.json',
],
id: `${nconf.get('url')}/category/${cid}`,
url: `${nconf.get('url')}/category/${slug}`,

View File

@@ -265,8 +265,8 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
await Notes.syncUserInboxes(tid, uid);
if (!hasTid && options.cid) {
// New topic, have category announce it
if (!hasTid && uid && options.cid) {
// New topic via search/post-redirect, have category announce it
activitypub.out.announce.topic(tid);
}

View File

@@ -277,6 +277,32 @@ Out.like.note = enabledCheck(async (uid, pid) => {
]);
});
Out.dislike = {};
Out.dislike.note = enabledCheck(async (uid, pid) => {
const payload = {
id: `${nconf.get('url')}/uid/${uid}#activity/dislike/${encodeURIComponent(pid)}`,
type: 'Dislike',
actor: `${nconf.get('url')}/uid/${uid}`,
object: utils.isNumber(pid) ? `${nconf.get('url')}/post/${pid}` : pid,
};
if (!activitypub.helpers.isUri(pid)) { // only 1b12 announce for local likes
await activitypub.feps.announce(pid, payload);
return;
}
const recipient = await posts.getPostField(pid, 'uid');
if (!activitypub.helpers.isUri(recipient)) {
return;
}
await Promise.all([
activitypub.send('uid', uid, [recipient], payload),
activitypub.feps.announce(pid, payload),
]);
});
Out.announce = {};
Out.announce.topic = enabledCheck(async (tid) => {

View File

@@ -126,7 +126,7 @@ categoriesAPI.getTopics = async (caller, data) => {
throw new Error('[[error:no-privileges]]');
}
const infScrollTopicsPerPage = 20;
const infScrollTopicsPerPage = settings.topicsPerPage;
const sort = data.sort || data.categoryTopicSort || meta.config.categoryTopicSort || 'recently_replied';
let start = Math.max(0, parseInt(data.after || 0, 10));

View File

@@ -147,14 +147,34 @@ async function executeCommand(caller, command, eventName, notification, data) {
websockets.in(`uid_${caller.uid}`).emit(`posts.${command}`, result);
websockets.in(data.room_id).emit(`event:${eventName}`, result);
}
if (result && command === 'upvote') {
socketHelpers.upvote(result, notification);
await activitypub.out.like.note(caller.uid, data.pid);
} else if (result && notification) {
socketHelpers.sendNotificationToPostOwner(data.pid, caller.uid, command, notification);
} else if (result && command === 'unvote') {
socketHelpers.rescindUpvoteNotification(data.pid, caller.uid);
await activitypub.out.undo.like(caller.uid, data.pid);
if (result) {
switch (command) {
case 'upvote': {
socketHelpers.upvote(result, notification);
await activitypub.out.like.note(caller.uid, data.pid);
break;
}
case 'downvote': {
await activitypub.out.dislike.note(caller.uid, data.pid);
break;
}
case 'unvote': {
socketHelpers.rescindUpvoteNotification(data.pid, caller.uid);
await activitypub.out.undo.like(caller.uid, data.pid);
break;
}
default: {
if (notification) {
socketHelpers.sendNotificationToPostOwner(data.pid, caller.uid, command, notification);
}
break;
}
}
}
return result;
}

View File

@@ -309,6 +309,7 @@ topicsAPI.move = async (caller, { tid, cid }) => {
throw new Error('[[error:no-privileges]]');
}
const topicData = await topics.getTopicFields(tid, ['tid', 'cid', 'mainPid', 'slug', 'deleted']);
topicData.toCid = cid;
if (!cids.includes(topicData.cid)) {
cids.push(topicData.cid);
}

View File

@@ -65,7 +65,7 @@ mongoModule.init = async function (opts) {
};
mongoModule.createSessionStore = async function (options) {
const MongoStore = require('connect-mongo');
const { MongoStore } = require('connect-mongo');
const meta = require('../meta');
const store = MongoStore.create({

View File

@@ -68,6 +68,31 @@ module.exports = function (module) {
}
};
module.setAddBulk = async function (data) {
if (!data.length) {
return;
}
const bulk = module.client.collection('objects').initializeUnorderedBulkOp();
data.forEach(([key, member]) => {
bulk.find({ _key: key }).upsert().updateOne({
$addToSet: {
members: helpers.valueToString(member),
},
});
});
try {
await bulk.execute();
} catch (err) {
if (err && err.message.includes('E11000 duplicate key error')) {
console.log(new Error('e11000').stack, data);
return await module.setAddBulk(data);
}
throw err;
}
};
module.setRemove = async function (key, value) {
if (!Array.isArray(value)) {
value = [value];
@@ -75,11 +100,17 @@ module.exports = function (module) {
value = value.map(v => helpers.valueToString(v));
await module.client.collection('objects').updateMany({
const coll = module.client.collection('objects');
await coll.updateMany({
_key: Array.isArray(key) ? { $in: key } : key,
}, {
$pullAll: { members: value },
});
await coll.deleteMany({
_key: Array.isArray(key) ? { $in: key } : key,
members: { $size: 0 },
});
};
module.setsRemove = async function (keys, value) {
@@ -88,11 +119,17 @@ module.exports = function (module) {
}
value = helpers.valueToString(value);
await module.client.collection('objects').updateMany({
const coll = module.client.collection('objects');
await coll.updateMany({
_key: { $in: keys },
}, {
$pull: { members: value },
});
await coll.deleteMany({
_key: { $in: keys },
members: { $size: 0 },
});
};
module.isSetMember = async function (key, value) {

View File

@@ -1,5 +1,6 @@
'use strict';
const fs = require('fs');
const nconf = require('nconf');
const winston = require('winston');
const _ = require('lodash');
@@ -32,6 +33,18 @@ connection.getConnectionOptions = function (postgres) {
connectionTimeoutMillis: 90000,
};
if (typeof postgres.ssl === 'object' && !Array.isArray(postgres.ssl) && postgres.ssl !== null) {
const { ssl } = postgres;
connOptions.ssl = {
rejectUnauthorized: ssl.rejectUnauthorized,
};
['ca', 'key', 'cert'].forEach((prop) => {
if (ssl.hasOwnProperty(prop)) {
connOptions.ssl[prop] = fs.readFileSync(ssl[prop]).toString();
}
});
}
return _.merge(connOptions, postgres.options || {});
};

View File

@@ -28,6 +28,11 @@ module.exports = function (module) {
return members.map(member => member.length > 0);
}
async function checkIfSetsExist(keys) {
const members = await Promise.all(keys.map(module.getSetMembers));
return members.map(member => member.length > 0);
}
async function checkIfKeysExist(keys) {
const res = await module.pool.query({
name: 'existsArray',
@@ -44,13 +49,16 @@ module.exports = function (module) {
if (isArray) {
const types = await Promise.all(key.map(module.type));
const zsetKeys = key.filter((_key, i) => types[i] === 'zset');
const otherKeys = key.filter((_key, i) => types[i] !== 'zset');
const [zsetExits, otherExists] = await Promise.all([
const setKeys = key.filter((_key, i) => types[i] === 'set');
const otherKeys = key.filter((_key, i) => types[i] !== 'zset' && types[i] !== 'set');
const [zsetExits, setExists, otherExists] = await Promise.all([
checkIfzSetsExist(zsetKeys),
checkIfSetsExist(setKeys),
checkIfKeysExist(otherKeys),
]);
const existsMap = Object.create(null);
zsetKeys.forEach((k, i) => { existsMap[k] = zsetExits[i]; });
setKeys.forEach((k, i) => { existsMap[k] = setExists[i]; });
otherKeys.forEach((k, i) => { existsMap[k] = otherExists[i]; });
return key.map(k => existsMap[k]);
}
@@ -58,6 +66,9 @@ module.exports = function (module) {
if (type === 'zset') {
const members = await module.getSortedSetRange(key, 0, 0);
return members.length > 0;
} else if (type === 'set') {
const members = await module.getSetMembers(key);
return members.length > 0;
}
const res = await module.pool.query({
name: 'exists',

View File

@@ -54,6 +54,32 @@ DO NOTHING`,
});
};
module.setAddBulk = async function (data) {
if (!data.length) {
return;
}
const keys = [];
const members = [];
for (const [key, member] of data) {
keys.push(key);
members.push(member);
}
await module.transaction(async (client) => {
await helpers.ensureLegacyObjectsType(client, keys, 'set');
await client.query({
name: 'setAddBulk',
text: `
INSERT INTO "legacy_set" ("_key", "member")
SELECT k, m
FROM UNNEST($1::TEXT[], $2::TEXT[]) AS t(k, m)
ON CONFLICT ("_key", "member")
DO NOTHING;`,
values: [keys, members],
});
});
};
module.setRemove = async function (key, value) {
if (!Array.isArray(key)) {
key = [key];

View File

@@ -22,7 +22,6 @@ connection.connect = async function (options) {
const sentinelRootNodes = options.sentinels.map(sentinel => ({ host: sentinel.host, port: sentinel.port }));
cxn = createSentinel({
...options.options,
name: 'sentinel-db',
sentinelRootNodes,
});
} else if (redis_socket_or_host && String(redis_socket_or_host).indexOf('/') >= 0) {

View File

@@ -14,11 +14,29 @@ module.exports = function (module) {
};
module.setsAdd = async function (keys, value) {
if (!Array.isArray(keys) || !keys.length) {
if (!Array.isArray(keys) || !keys.length || !value) {
return;
}
if (!Array.isArray(value)) {
value = [value];
}
if (!value.length) {
return;
}
const batch = module.client.batch();
keys.forEach(k => batch.sAdd(String(k), String(value)));
keys.forEach((k) => {
value.forEach(v => batch.sAdd(String(k), String(v)));
});
await helpers.execBatch(batch);
};
module.setAddBulk = async function (data) {
if (!data.length) {
return;
}
const batch = module.client.batch();
data.forEach(([key, member]) => batch.sAdd(String(key), String(member)));
await helpers.execBatch(batch);
};

View File

@@ -177,16 +177,18 @@ module.exports = function (Posts) {
}
const now = Date.now();
if (type === 'upvote' && !unvote) {
await db.sortedSetAdd(`uid:${uid}:upvote`, now, pid);
} else {
await db.sortedSetRemove(`uid:${uid}:upvote`, pid);
}
if (utils.isNumber(uid)) {
if (type === 'upvote' && !unvote) {
await db.sortedSetAdd(`uid:${uid}:upvote`, now, pid);
} else {
await db.sortedSetRemove(`uid:${uid}:upvote`, pid);
}
if (type === 'upvote' || unvote) {
await db.sortedSetRemove(`uid:${uid}:downvote`, pid);
} else {
await db.sortedSetAdd(`uid:${uid}:downvote`, now, pid);
if (type === 'upvote' || unvote) {
await db.sortedSetRemove(`uid:${uid}:downvote`, pid);
} else {
await db.sortedSetAdd(`uid:${uid}:downvote`, now, pid);
}
}
const postData = await Posts.getPostFields(pid, ['pid', 'uid', 'tid']);

View File

@@ -96,14 +96,16 @@ privsCategories.get = async function (cid, uid) {
'topics:tag', 'read', 'posts:view_deleted',
];
const [userPrivileges, isAdministrator, isModerator] = await Promise.all([
let [userPrivileges, isAdministrator, isModerator] = await Promise.all([
helpers.isAllowedTo(privs, uid, cid),
user.isAdministrator(uid),
user.isModerator(uid, cid),
]);
const combined = userPrivileges.map(allowed => allowed || isAdministrator);
const privData = _.zipObject(privs, combined);
if (utils.isNumber(cid)) {
userPrivileges = userPrivileges.map(allowed => allowed || isAdministrator);
}
const privData = _.zipObject(privs, userPrivileges);
const isAdminOrMod = isAdministrator || isModerator;
return await plugins.hooks.fire('filter:privileges.categories.get', {

View File

@@ -4,6 +4,7 @@
const _ = require('lodash');
const validator = require('validator');
const db = require('../database');
const groups = require('../groups');
const user = require('../user');
const categories = require('../categories');
@@ -25,16 +26,25 @@ helpers.isUsersAllowedTo = async function (privilege, uids, cid) {
cid = -1;
}
const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([
groups.isMembers(uids, `cid:${cid}:privileges:${privilege}`),
groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:${privilege}`),
]);
const allowed = uids.map((uid, index) => hasUserPrivilege[index] || hasGroupPrivilege[index]);
const result = await plugins.hooks.fire('filter:privileges:isUsersAllowedTo', { allowed: allowed, privilege: privilege, uids: uids, cid: cid });
let allowed;
const masked = await db.isSetMember(`cid:${cid}:privilegeMask`, privilege);
if (!masked) {
const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([
groups.isMembers(uids, `cid:${cid}:privileges:${privilege}`),
groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:${privilege}`),
]);
allowed = uids.map((uid, index) => hasUserPrivilege[index] || hasGroupPrivilege[index]);
} else {
allowed = uids.map(() => false);
}
const result = await plugins.hooks.fire('filter:privileges:isUsersAllowedTo', { allowed, privilege, uids, cid });
return result.allowed;
};
helpers.isAllowedTo = async function (privilege, uidOrGroupName, cid) {
const _cid = cid; // original passed-in cid needed for privilege mask checks
// Remote categories (non-numeric) inherit world privileges
if (Array.isArray(cid)) {
cid = cid.map(cid => (utils.isNumber(cid) ? cid : -1));
@@ -44,10 +54,15 @@ helpers.isAllowedTo = async function (privilege, uidOrGroupName, cid) {
let allowed;
if (Array.isArray(privilege) && !Array.isArray(cid)) {
const mask = await db.isSetMembers(`cid:${_cid}:privilegeMask`, privilege);
allowed = await isAllowedToPrivileges(privilege, uidOrGroupName, cid);
allowed = allowed.map((allowed, idx) => mask[idx] ? false : allowed);
} else if (Array.isArray(cid) && !Array.isArray(privilege)) {
const mask = await db.isMemberOfSets(_cid.map(cid => `cid:${cid}:privilegeMask`), privilege);
allowed = await isAllowedToCids(privilege, uidOrGroupName, cid);
allowed = allowed.map((allowed, idx) => mask[idx] ? false : allowed);
}
if (allowed) {
({ allowed } = await plugins.hooks.fire('filter:privileges:isAllowedTo', { allowed: allowed, privilege: privilege, uid: uidOrGroupName, cid: cid }));
return allowed;

View File

@@ -290,7 +290,7 @@ module.exports = function (Topics) {
};
Topics.markAsRead = async function (tids, uid) {
if (!Array.isArray(tids) || !tids.length) {
if (!Array.isArray(tids) || !tids.length || !utils.isNumber(uid)) {
return false;
}

View File

@@ -0,0 +1,24 @@
'use strict';
const db = require('../../database');
const batch = require('../../batch');
module.exports = {
name: 'Remove extraneous upvote and tids_read data for remote users',
timestamp: Date.UTC(2025, 11, 11),
method: async function () {
const { progress } = this;
await batch.processSortedSet('usersRemote:lastCrawled', async (uids) => {
const readKeys = uids.map(uid => `uid:${uid}:tids_read`);
const voteKeys = uids.map(uid => `uid:${uid}:upvote`);
const combined = readKeys.concat(voteKeys);
await db.deleteAll(combined);
progress.incr(uids.length);
}, {
batch: 500,
progress,
});
},
};

View File

@@ -164,6 +164,7 @@ describe('FEPs', () => {
});
pid = id;
({ activity } = await helpers.mocks.create(note));
await activitypub.inbox.create({ body: activity });
const activities = Array.from(activitypub._sent);
@@ -180,7 +181,6 @@ describe('FEPs', () => {
return activity.type === 'Announce' &&
activity.object && activity.object.type === 'Note';
});
assert(test1 && test2);
});

View File

@@ -432,6 +432,7 @@ describe('Notes', () => {
describe('Create', () => {
let uid;
let cid;
before(async () => {
uid = await user.create({ username: utils.generateUUID() });
@@ -451,6 +452,17 @@ describe('Notes', () => {
assert.strictEqual(cid, -1);
});
it('should not append to the tids_read sorted set', async () => {
const { note, id } = helpers.mocks.note();
const { activity } = helpers.mocks.create(note);
await db.sortedSetAdd(`followersRemote:${note.attributedTo}`, Date.now(), uid);
await activitypub.inbox.create({ body: activity });
const exists = await db.exists(`uid:${note.attributedTo}:tids_read`);
assert(!exists);
});
it('should create a new topic in a remote category if addressed (category same-origin)', async () => {
const { id: remoteCid } = helpers.mocks.group();
const { note, id } = helpers.mocks.note({
@@ -467,40 +479,63 @@ describe('Notes', () => {
});
it('should create a new topic in cid -1 if a non-same origin remote category is addressed', async function () {
this.timeout(30000);
const start = Date.now();
const { id: remoteCid } = helpers.mocks.group({
id: `https://example.com/${utils.generateUUID()}`,
});
console.log('1', Date.now() - start);
const { note, id } = helpers.mocks.note({
audience: [remoteCid],
});
console.log('2', Date.now() - start);
const { activity } = helpers.mocks.create(note);
console.log('3', Date.now() - start);
try {
await activitypub.inbox.create({ body: activity });
} catch (err) {
console.log('error in test', err.stack);
assert(false);
}
console.log('4', Date.now() - start);
assert(await posts.exists(id));
console.log('5', Date.now() - start);
const cid = await posts.getCidByPid(id);
console.log('6', Date.now() - start);
assert.strictEqual(cid, -1);
});
});
describe('(Like)', () => {
let pid;
let voterUid;
before(async () => {
({ cid } = await categories.create({ name: utils.generateUUID() }));
const { postData } = await topics.post({
uid,
cid,
title: utils.generateUUID(),
content: utils.generateUUID(),
});
pid = postData.pid;
const object = await activitypub.mocks.notes.public(postData);
const { activity } = helpers.mocks.like({ object });
voterUid = activity.actor;
await activitypub.inbox.like({ body: activity });
});
it('should increment a like for the post', async () => {
const voted = await posts.hasVoted(pid, voterUid);
const count = await posts.getPostField(pid, 'upvotes');
assert(voted);
assert.strictEqual(count, 1);
});
it('should not append to the uid upvotes zset', async () => {
const exists = await db.exists(`uid:${voterUid}:upvote`);
assert(!exists);
});
});
});
describe('Announce', () => {
let cid;
before(async () => {
({ cid } = await categories.create({ name: utils.generateUUID().slice(0, 8) }));
({ cid } = await categories.create({ name: utils.generateUUID() }));
});
describe('(Create)', () => {

View File

@@ -203,4 +203,241 @@ describe('Privilege logic for remote users/content (ActivityPub)', () => {
});
});
});
});
describe('Privilege masking', () => {
before(async () => {
// Grant default fediverse privileges
await install.giveWorldPrivileges();
});
describe('control', () => {
let cid;
let uid;
before(async () => {
// Set up a standard mock group
({ id: cid } = helpers.mocks.group());
// Unprivileged user for testing
uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
});
it('should properly assert the remote category', async () => {
const assertion = await activitypub.actors.assertGroup([cid]);
const exists = await categories.exists(cid);
assert(assertion);
assert(exists);
});
it('should not set up a privilege mask for that category', async () => {
const exists = await db.exists(`cid:${cid}:privilegeMask`);
assert(!exists);
});
it('should pass the privileges .can() check if requested', async () => {
const set = await privileges.categories.get(cid, uid);
const can = await privileges.categories.can('topics:create', cid, uid);
assert(can);
});
it('should return true in the privilege set when requested', async () => {
const set = await privileges.categories.get(cid, uid);
assert(set);
assert(set['topics:create']);
});
});
describe('postingRestrictedToMods (true on assert)', () => {
let cid;
let uid;
before(async () => {
// Set up a mock group with `postingRestrictedToMods` bit set
({ id: cid } = helpers.mocks.group({
postingRestrictedToMods: true,
}));
// Unprivileged user for testing
uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
});
it('should properly assert the remote category', async () => {
const assertion = await activitypub.actors.assertGroup([cid]);
const exists = await categories.exists(cid);
assert(assertion);
assert(exists);
});
it('should set up a privilege mask for that category', async () => {
const exists = await db.exists(`cid:${cid}:privilegeMask`);
assert(exists);
});
it('should contain a single mask', async () => {
const members = await db.getSetMembers(`cid:${cid}:privilegeMask`);
assert(members);
assert.strictEqual(members.length, 1);
assert.strictEqual(members[0], 'topics:create');
});
it('should fail the privileges .can() check if requested', async () => {
const can = await privileges.categories.can('topics:create', cid, uid);
assert(!can);
});
it('should return false in the privilege set when requested', async () => {
const set = await privileges.categories.get(cid, uid);
assert(set);
assert(!set['topics:create']);
});
});
describe('postingRestrictedToMods (true on assert and re-assertion)', () => {
let cid;
let uid;
before(async () => {
// Set up a mock group with `postingRestrictedToMods` bit set
({ id: cid } = helpers.mocks.group({
postingRestrictedToMods: true,
}));
// Unprivileged user for testing
uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
// Assert group, then re-assert again
await activitypub.actors.assertGroup([cid]);
await activitypub.actors.assertGroup([cid], { update: true });
});
it('should fail the privileges .can() check if requested', async () => {
const can = await privileges.categories.can('topics:create', cid, uid);
assert(!can);
});
it('should return false in the privilege set when requested', async () => {
const set = await privileges.categories.get(cid, uid);
assert(set);
assert(!set['topics:create']);
});
});
describe('postingRestrictedToMods (true on assert, false on update)', () => {
let cid;
let uid;
before(async () => {
// Set up a mock group with `postingRestrictedToMods` bit set
({ id: cid } = helpers.mocks.group({
postingRestrictedToMods: true,
}));
// Unprivileged user for testing
uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
await activitypub.actors.assertGroup([cid]);
// Group updated "remotely"
helpers.mocks.group({
id: cid,
postingRestrictedToMods: false,
});
});
it('should remove the privilege mask if the bit is not present on group actor update', async () => {
let can = await privileges.categories.can('topics:create', cid, uid);
assert(!can, 'Initial state should be denied due to mask.');
// Group re-assertion
await activitypub.actors.assertGroup([cid], { update: true });
// Ensure mask is gone from db
const memberCount = await db.setCount(`cid:${cid}:privilegeMask`);
assert.strictEqual(memberCount, 0);
can = await privileges.categories.can('topics:create', cid, uid);
assert(can, 'Privilege should be restored after mask removal.');
});
});
describe('postingRestrictedToMods (true on assert, property missing on update)', () => {
let cid;
let uid;
before(async () => {
// Set up a mock group with `postingRestrictedToMods` bit set
({ id: cid } = helpers.mocks.group({
postingRestrictedToMods: true,
}));
// Unprivileged user for testing
uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
await activitypub.actors.assertGroup([cid]);
// Group updated "remotely"
helpers.mocks.group({
id: cid,
});
});
it('should remove the privilege mask if the bit is not present on group actor update', async () => {
let can = await privileges.categories.can('topics:create', cid, uid);
assert(!can, 'Initial state should be denied due to mask.');
// Group re-assertion
await activitypub.actors.assertGroup([cid], { update: true });
// Ensure mask is gone from db
const memberCount = await db.setCount(`cid:${cid}:privilegeMask`);
assert.strictEqual(memberCount, 0);
can = await privileges.categories.can('topics:create', cid, uid);
assert(can, 'Privilege should be restored after mask removal.');
});
});
describe('postingRestrictedToMods (false on assert, true on update)', () => {
let cid;
let uid;
before(async () => {
// Set up a mock group with `postingRestrictedToMods` bit set to false
({ id: cid } = helpers.mocks.group({
postingRestrictedToMods: false,
}));
// Unprivileged user for testing
uid = await user.create({ username: utils.generateUUID().slice(0, 10) });
await activitypub.actors.assertGroup([cid]);
// Update group "remotely", re-assert
helpers.mocks.group({
id: cid,
postingRestrictedToMods: true,
});
await activitypub.actors.assertGroup([cid], { update: true });
});
it('should fail the privileges .can() check if requested', async () => {
const can = await privileges.categories.can('topics:create', cid, uid);
assert(!can);
});
it('should return false in the privilege set when requested', async () => {
const set = await privileges.categories.get(cid, uid);
assert(set);
assert(!set['topics:create']);
});
});
});

View File

@@ -1,10 +1,11 @@
'use strict';
const db = require('./mocks/databasemock');
const assert = require('assert');
const nconf = require('nconf');
const request = require('../src/request');
const db = require('./mocks/databasemock');
const Categories = require('../src/categories');
const Topics = require('../src/topics');
const User = require('../src/user');

View File

@@ -88,6 +88,36 @@ describe('Set methods', () => {
done();
});
});
it('should add the values to each set', async () => {
await db.setsAdd(['saddarray1', 'saddarray2', 'saddarray3'], ['v1', 'v2', 'v3']);
const data = await db.getSetsMembers(['saddarray1', 'saddarray2', 'saddarray3']);
data.forEach(members => members.sort());
assert.deepStrictEqual(data, [
['v1', 'v2', 'v3'],
['v1', 'v2', 'v3'],
['v1', 'v2', 'v3'],
]);
});
});
describe('setAddBulk()', () => {
it('should add multiple key-member pairs', async () => {
await db.setAddBulk([
['bulkSet1', 'value1'],
['bulkSet2', 'value2'],
]);
let data = await db.getSetMembers('bulkSet1');
assert.deepStrictEqual(data, ['value1']);
data = await db.getSetMembers('bulkSet2');
assert.deepStrictEqual(data, ['value2']);
await db.setAddBulk([
['bulkSet1', 'value1'],
['bulkSet1', 'value3'],
]);
data = await db.getSetMembers('bulkSet1');
assert.deepStrictEqual(data.sort(), ['value1', 'value3']);
});
});
describe('getSetsMembers()', () => {
@@ -208,57 +238,42 @@ describe('Set methods', () => {
});
describe('setRemove()', () => {
before((done) => {
db.setAdd('testSet6', [1, 2], done);
it('should remove an element from set', async () => {
await db.setAdd('testSet6', [1, 2]);
await db.setRemove('testSet6', '2');
const isMember = await db.isSetMember('testSet6', '2');
assert.equal(isMember, false);
});
it('should remove a element from set', (done) => {
db.setRemove('testSet6', '2', function (err) {
assert.equal(err, null);
assert.equal(arguments.length, 1);
it('should remove multiple elements from set', async () => {
await db.setAdd('multiRemoveSet', [1, 2, 3, 4, 5]);
await db.setRemove('multiRemoveSet', [1, 3, 5]);
db.isSetMember('testSet6', '2', (err, isMember) => {
assert.equal(err, null);
assert.equal(isMember, false);
done();
});
});
const members = await db.getSetMembers('multiRemoveSet');
assert(members.includes('2'));
assert(members.includes('4'));
});
it('should remove multiple elements from set', (done) => {
db.setAdd('multiRemoveSet', [1, 2, 3, 4, 5], (err) => {
assert.ifError(err);
db.setRemove('multiRemoveSet', [1, 3, 5], (err) => {
assert.ifError(err);
db.getSetMembers('multiRemoveSet', (err, members) => {
assert.ifError(err);
assert(members.includes('2'));
assert(members.includes('4'));
done();
});
});
});
it('should remove multiple values from multiple keys', async () => {
await db.setAdd('multiSetTest1', ['one', 'two', 'three', 'four']);
await db.setAdd('multiSetTest2', ['three', 'four', 'five', 'six']);
await db.setRemove(['multiSetTest1', 'multiSetTest2'], ['three', 'four', 'five', 'doesnt exist']);
const members = await db.getSetsMembers(['multiSetTest1', 'multiSetTest2']);
assert.equal(members[0].length, 2);
assert.equal(members[1].length, 1);
assert(members[0].includes('one'));
assert(members[0].includes('two'));
assert(members[1].includes('six'));
});
it('should remove multiple values from multiple keys', (done) => {
db.setAdd('multiSetTest1', ['one', 'two', 'three', 'four'], (err) => {
assert.ifError(err);
db.setAdd('multiSetTest2', ['three', 'four', 'five', 'six'], (err) => {
assert.ifError(err);
db.setRemove(['multiSetTest1', 'multiSetTest2'], ['three', 'four', 'five', 'doesnt exist'], (err) => {
assert.ifError(err);
db.getSetsMembers(['multiSetTest1', 'multiSetTest2'], (err, members) => {
assert.ifError(err);
assert.equal(members[0].length, 2);
assert.equal(members[1].length, 1);
assert(members[0].includes('one'));
assert(members[0].includes('two'));
assert(members[1].includes('six'));
done();
});
});
});
});
it('should remove set if all elements are removed', async () => {
await db.setAdd('toBeDeletedSet', ['a', 'b']);
await db.setRemove('toBeDeletedSet', ['a', 'b']);
const exists = await db.exists('toBeDeletedSet');
assert.equal(exists, false);
});
});

View File

@@ -107,7 +107,7 @@ describe('helpers', () => {
imageClass: 'auto',
name: 'Category 1',
}, 'a', ''),
`<a href="${nconf.get('relative_path')}/category/undefined" class="badge px-1 text-truncate text-decoration-none " style="color: #00ff00;background-color: #ff0000;border-color: #ff0000!important; max-width: 70vw;">\n\t\t\t\n\t\t\tCategory 1\n\t\t</a>`
`<a component="topic/category" href="${nconf.get('relative_path')}/category/undefined" class="badge px-1 text-truncate text-decoration-none " style="color: #00ff00;background-color: #ff0000;border-color: #ff0000!important; max-width: 70vw;">\n\t\t\t\n\t\t\tCategory 1\n\t\t</a>`
);
assert.strictEqual(
helpers.buildCategoryLabel({
@@ -118,7 +118,7 @@ describe('helpers', () => {
name: 'Category 1',
icon: 'fa-book',
}, 'span', 'rounded-1'),
`<span class="badge px-1 text-truncate text-decoration-none rounded-1" style="color: #00ff00;background-color: #ff0000;border-color: #ff0000!important; max-width: 70vw;">\n\t\t\t<i class="fa fa-fw fa-book"></i>\n\t\t\tCategory 1\n\t\t</span>`,
`<span component="topic/category" class="badge px-1 text-truncate text-decoration-none rounded-1" style="color: #00ff00;background-color: #ff0000;border-color: #ff0000!important; max-width: 70vw;">\n\t\t\t<i class="fa fa-fw fa-book"></i>\n\t\t\tCategory 1\n\t\t</span>`,
);
done();
});