diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a5a08358a..622b15cbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,151 @@ +#### v1.19.2 (2022-02-09) + +##### Chores + +* up persona (14ecafb6) +* up markdown (8a4b7dc4) +* add missing quote (b98758d3) +* use source and current local vars, + docs (9e4147f0) +* up persona (1eaae1d0) +* up emoji (106ef7cf) +* persona (3b4cf971) +* persona (78db61cf) +* up deps (c7a56439) +* add punycode dependency (452f29c0) +* up persona (d50d4a9e) +* up persona (458606bc) +* up persona (cfe53305) +* up persona (f29bed27) +* up packages (b4a4e60e) +* up persona (3e30b6cd) +* incrementing version number - v1.19.1 (7f450268) +* update changelog for v1.19.1 (55df683a) +* **deps:** + * bump ioredis from 4.28.4 to 4.28.5 in /install (#10254) (b496ad44) + * bump nodebb-widget-essentials in /install (#10219) (b71025ce) + * update dependency lint-staged to v12.3.3 (6ba25557) + * update dependency eslint to v8.8.0 (153693e0) + * bump nodebb-theme-persona in /install (#10199) (2db54e67) + * update dependency lint-staged to v12.3.2 (814cb66b) + * update dependency mocha to v9.2.0 (05e2b354) + * bump helmet from 5.0.1 to 5.0.2 in /install (1f037bf6) + * update dependency lint-staged to v12.3.1 (ac244af3) + * update dependency lint-staged to v12.3.0 (7060837b) + * bump helmet from 4.6.0 to 5.0.1 in /install (5d3900dc) +* **i18n:** + * fallback strings for new resources: nodebb.modules (a71b8e59) + * fallback strings for new resources: nodebb.global, nodebb.pages (aa812f03) + * fallback strings for new resources: nodebb.users (70eeb204) + * fallback strings for new resources: nodebb.admin-settings-email (e9588ca7) + * fallback strings for new resources: nodebb.admin-settings-advanced (2ec4e31f) + +##### Documentation Changes + +* openapi spec for new route (9b912db7) +* some tweaks to cli help (c869d7db) + +##### New Features + +* handle array of keys in psql exists for zsets (5143ca33) +* upgrade script to clean up leftover :thumb zsets (0ac28435) +* more tests for ensuring downvoted posts are added to the :votes zset (1b8eeaf8) +* upgrade script to store downvotes posts in the user :votes sorted set (cf88483f) +* new accounts route to show most downvoted ('controversial') posts (5afd5de0) +* v3 user email tests (aa8914a1) +* allow gif profile images, sharp 0.30.0 supports gifs (7f1c4477) +* detect alternative package managers based on lockfile (8ba9e67c) +* new language key for user search in chat (766ad6b7) +* remove colors in favour of chalk (#10142) (cf8f62ae) +* add upload helper module for drag&drop, paste, closes #6388 (cf5c0968) +* no more sending emails to banned users, + feature flag (ea27eaf1) +* push the theme name into body class (e1e1d522) +* add ACP toggles for COEP and CORP headers (d91aeea3) + +##### Bug Fixes + +* **deps:** + * update dependency sharp to v0.30.1 (#10270) (8e52abe8) + * update dependency nodebb-widget-essentials to v5.0.7 (#10269) (6c0f7034) + * update dependency nodebb-theme-persona to v11.3.37 (#10265) (78d48c37) + * update dependency ioredis to v4.28.5 (#10252) (721a70c0) + * update dependency connect-redis to v6.1.1 (#10260) (a10e4940) + * update dependency nodebb-theme-persona to v11.3.36 (#10253) (0e2a4a2d) + * update dependency nodebb-theme-persona to v11.3.35 (#10251) (6465e012) + * update dependency pg-cursor to v2.7.3 (#10244) (e6185883) + * update dependency nodebb-theme-persona to v11.3.33 (#10248) (32477676) + * update dependency nodebb-theme-vanilla to v12.1.17 (#10249) (8f5b5ef1) + * update dependency nodebb-plugin-emoji to v3.5.9 (#10250) (1eb0939e) + * update dependency sanitize-html to v2.7.0 (#10246) (845717b8) + * update dependency pg to v8.7.3 (#10243) (531a3b1e) + * update dependency connect-redis to v6.1.0 (#10245) (c343b631) + * update dependency nodebb-theme-persona to v11.3.31 (#10241) (f1bed441) + * update dependency nodebb-plugin-composer-default to v7.0.20 (#10231) (a4702959) + * update dependency nodebb-theme-persona to v11.3.30 (#10232) (916a0db3) + * update dependency nodebb-plugin-emoji to v3.5.8 (#10239) (ebf4e12b) + * update dependency sharp to v0.30.0 (#10221) (2924cd3b) + * update dependency ioredis to v4.28.4 (#10224) (cda07cb7) + * update dependency clipboard to v2.0.10 (2c605d1c) + * update dependency sitemap to v7.1.1 (1bf938da) + * update dependency winston to v3.5.1 (b0dd68bb) + * pin dependency punycode to 2.1.1 (e7ba24c5) + * update dependency postcss to v8.4.6 (322f1033) + * update dependency nodebb-plugin-markdown to v9 (7d5080cd) + * update dependency ace-builds to v1.4.14 (#10200) (c50f6512) + * update dependency winston to v3.5.0 (#10202) (a7f142be) + * update dependency clipboard to v2.0.9 (#10203) (c6164e48) +* remove extraneous devDependencies on package merge (a2c7d69e) +* #10257, topic thumbs not deleting on topic deletion (0f788b8e) +* #10256, allow quote tooltip on mobile (fb3f4f9a) +* #10255, create verified/unverified groups on install (08f2a050) +* controversial posts/bests posts not showing anything (079c487d) +* regression in package.json merging logic that caused extraneous packages to not be removed (d34471f6) +* #10229, package merging should deep merge nested objects (689c125c) +* use fs.promises (a0a38706) +* bug where .reduce() exploded due to no initial value, if input value was an empty array (5cff6e3f) +* https://github.com/NodeBB/NodeBB/issues/10242 (dcb201df) +* missing early return (ad635175) +* handle case where email is explicitly passed into user.create, and thus is set in user hash, but confirmation request may have expired (936562c3) +* #10236, don't check email:uid, instead verify an email confirmation is active (0322e984) +* don't crash if requestedFields is undefined (98839108) +* a missed invocation of colors (c3d926ff) +* proactively guard against homograph characters in website values (fa7dcdb9) +* #10208, don't use leading slash in directory names (1d01741a) +* don't crash if quick search doesn't return posts (93d18383) +* properly unregister hooks in emailer tests (fc2c755c) +* email ban tests (dee9cca3) +* update usage of emailer.send to not catch (as errors are no longer thrown), email error throttler (d4e5259f) +* derp (b3f7b742) +* bug where page wouldn't complete loading if data.scripts was emptied (578145ac) +* use escaped group names in invite modal (2a89ad82) +* https://github.com/julianlam/nodebb-plugin-mentions/issues/170 (dc6e629d) +* #10197, fix relative path urls for dashboard pages (92a249c9) +* actually, CORP is ok (df8c8ad8) +* update defaults for corp and coep to be more permissive, for now, to be reverted for v1.20.0 (4467299e) +* if no group label is selected, select no group title option (94da5026) + +##### Other Changes + +* remove unused require (6be330f2) + +##### Performance Improvements + +* increase batch size (b548083b) + +##### Refactors + +* update chat plcaeholder message (fbd9ba79) +* updated package-install.js exports style, new exported method 'getPackageManager' for use in cases where nconf is unreliable, fix bug where nconf was not correctly set up in cli tools, proper installation of dev dependencies based on global env value (9a169085) +* emailer.send and emailer.sendToEmail returns Boolean based on message being successfully sent (f0e32ff1) +* sorted-list .get() to be async fn (89b559a2) + +##### Tests + +* fix occasional test failure (2dbdd181) +* add test to verify that a sorted set is automatically deleted if its last element is removed (#10261) (60680876) +* stricter isValidationPending check (d1b1f50b) +* fix derp (680e36da) +* up acp plugin page timeout (a214f9a6) + #### v1.19.1 (2022-01-21) ##### Chores diff --git a/Dockerfile b/Dockerfile index 80dce7cfb5..8a5b7ae9bb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,4 +22,4 @@ ENV NODE_ENV=production \ EXPOSE 4567 -CMD node ./nodebb build ; node ./nodebb start +CMD test -n "${SETUP}" && ./nodebb setup || node ./nodebb build; node ./nodebb start diff --git a/install/package.json b/install/package.json index 64bb5403f4..808b4e0c7b 100644 --- a/install/package.json +++ b/install/package.json @@ -88,11 +88,11 @@ "nodebb-plugin-2factor": "3.0.4", "nodebb-plugin-composer-default": "7.0.20", "nodebb-plugin-dbsearch": "5.1.1", - "nodebb-plugin-emoji": "3.5.9", + "nodebb-plugin-emoji": "3.5.16", "nodebb-plugin-emoji-android": "2.0.5", - "nodebb-plugin-markdown": "9.0.5", - "nodebb-plugin-mentions": "3.0.4", - "nodebb-plugin-spam-be-gone": "0.7.12", + "nodebb-plugin-markdown": "9.0.7", + "nodebb-plugin-mentions": "3.0.5", + "nodebb-plugin-spam-be-gone": "0.7.13", "nodebb-rewards-essentials": "0.2.1", "nodebb-theme-lavender": "5.3.2", "nodebb-theme-persona": "11.3.38", @@ -109,7 +109,6 @@ "postcss": "8.4.6", "postcss-clean": "1.2.0", "prompt": "1.2.1", - "punycode": "2.1.1", "ioredis": "4.28.5", "request": "2.88.2", "request-promise-native": "1.0.9", @@ -137,7 +136,7 @@ "uglify-es": "3.3.9", "validator": "13.7.0", "visibilityjs": "2.0.2", - "winston": "3.5.1", + "winston": "3.6.0", "xml": "1.0.1", "xregexp": "5.1.0", "yargs": "17.3.1", @@ -145,17 +144,17 @@ }, "devDependencies": { "@apidevtools/swagger-parser": "10.0.3", - "@commitlint/cli": "16.1.0", - "@commitlint/config-angular": "16.0.0", + "@commitlint/cli": "16.2.1", + "@commitlint/config-angular": "16.2.1", "coveralls": "3.1.1", - "eslint": "8.8.0", + "eslint": "8.9.0", "eslint-config-nodebb": "0.1.1", "eslint-plugin-import": "2.25.4", "grunt": "1.4.1", "grunt-contrib-watch": "1.1.0", "husky": "7.0.4", "jsdom": "19.0.0", - "lint-staged": "12.3.3", + "lint-staged": "12.3.4", "mocha": "9.2.0", "mocha-lcov-reporter": "1.3.0", "mockdate": "3.0.5", diff --git a/public/language/ar/admin/settings/uploads.json b/public/language/ar/admin/settings/uploads.json index b384b1cb19..b4d742e783 100644 --- a/public/language/ar/admin/settings/uploads.json +++ b/public/language/ar/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "المشاركات", "private": "جعل الملفات التي تم رفعها خاصة", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/ar/user.json b/public/language/ar/user.json index e0125eb1a6..3ac5f91f63 100644 --- a/public/language/ar/user.json +++ b/public/language/ar/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "هذا المستخدم لم يقم بتجاهل اية مواضيع حتى الآن.", "has_no_upvoted_posts": "هذا المستخدم لم يقم بالتصويت للأعلى لأي مشاركة حتى الآن.", "has_no_downvoted_posts": "هذا المستخدم لم يقم بالتصويت للأسفل لأي مشاركة حتى الآن.", - "has_no_voted_posts": "هذا المستخدم لا يمتلك اية مشاركات تم التصويت عليها", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "البريد الإلكتروني مخفي", "hidden": "مخفي", diff --git a/public/language/bg/admin/settings/uploads.json b/public/language/bg/admin/settings/uploads.json index 13571f0d12..b2d8abf64b 100644 --- a/public/language/bg/admin/settings/uploads.json +++ b/public/language/bg/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Публикации", "private": "Качените файлове да бъдат частни", "strip-exif-data": "Премахване на данните EXIF", + "preserve-orphaned-uploads": "Запазване на качените файлове на диска дори след изтриването на публикацията", "private-extensions": "Файлови разширения, които да бъдат частни", "private-uploads-extensions-help": "Въведете списък от файлови разширения, разделени със запетаи, които искате да бъдат частни (например pdf,xls,doc). Ако оставите това поле празно, всички файлове ще бъдат частни.", "resize-image-width-threshold": "Преоразмеряване на изображенията, ако са по-широки от определената ширина", diff --git a/public/language/bg/user.json b/public/language/bg/user.json index a3ddb01fd9..d8a2d03e07 100644 --- a/public/language/bg/user.json +++ b/public/language/bg/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Този потребител не е пренебрегнал нито една тема досега.", "has_no_upvoted_posts": "Този потребител не е гласувал положително досега.", "has_no_downvoted_posts": "Този потребител не е гласувал отрицателно досега.", - "has_no_voted_posts": "Този потребител не е гласувал досега.", + "has_no_controversial_posts": "Този потребител няма публикации с отрицателни гласове засега.", "has_no_blocks": "Не сте блокирали никого.", "email_hidden": "Е-пощата е скрита", "hidden": "скрито", diff --git a/public/language/bn/admin/settings/uploads.json b/public/language/bn/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/bn/admin/settings/uploads.json +++ b/public/language/bn/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/bn/user.json b/public/language/bn/user.json index fcd811293e..3abc8984cf 100644 --- a/public/language/bn/user.json +++ b/public/language/bn/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "This user hasn't upvoted any posts yet.", "has_no_downvoted_posts": "This user hasn't downvoted any posts yet.", - "has_no_voted_posts": "This user has no voted posts", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "ইমেইল গোপন রাখা হয়েছে", "hidden": "গোপন করা হয়েছে", diff --git a/public/language/cs/admin/settings/uploads.json b/public/language/cs/admin/settings/uploads.json index d0b505e1c4..fec29d8147 100644 --- a/public/language/cs/admin/settings/uploads.json +++ b/public/language/cs/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Příspěvky", "private": "Nahrané soubory jsou soukromé", "strip-exif-data": "Nepoužít data EXIF", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "Přípona souborů je soukromá", "private-uploads-extensions-help": "Pro nastavení soukromí, zde zadejte seznam souborů oddělený čárkou (tj. pdf, xls,doc). prázdný seznam znamená, že všechny soubory jsou soukromé.", "resize-image-width-threshold": "Změnit velikost obrázků, jsou-li širší než určená šířka", diff --git a/public/language/cs/user.json b/public/language/cs/user.json index d2d3ab15ac..0b8d96ae47 100644 --- a/public/language/cs/user.json +++ b/public/language/cs/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Tento uživatel ještě neignoruje žádné témata.", "has_no_upvoted_posts": "Tento uživatel zatím nevyjádřil souhlas u žádného příspěvku.", "has_no_downvoted_posts": "Tento uživatel zatím nevyjádřil nesouhlas u žádného příspěvku.", - "has_no_voted_posts": "Tento uživatel nemá žádné hlasovací příspěvky", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Nezablokoval/a jste žádné uživatele.", "email_hidden": "E-mail je skryt", "hidden": "skrytý", diff --git a/public/language/da/admin/settings/uploads.json b/public/language/da/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/da/admin/settings/uploads.json +++ b/public/language/da/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/da/user.json b/public/language/da/user.json index 36d8c66a2e..65e8c94092 100644 --- a/public/language/da/user.json +++ b/public/language/da/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "Denne bruger har ikke syntes godt om nogle indlæg endnu.", "has_no_downvoted_posts": "Denne bruger har ikke, syntes ikke godt om nogle indlæg endnu.", - "has_no_voted_posts": "Denne bruger har ingen stemte indlæg", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Email Skjult", "hidden": "skjult", diff --git a/public/language/de/admin/settings/uploads.json b/public/language/de/admin/settings/uploads.json index 50955dacc8..8d02fea669 100644 --- a/public/language/de/admin/settings/uploads.json +++ b/public/language/de/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Beiträge", "private": "Hochgeladene Dateien privatisieren", "strip-exif-data": "EXIF-Daten entfernen", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "Private Dateiendungen", "private-uploads-extensions-help": "Gib eine Komma-Separierte Liste mit Dateiendungen an, die privatisiert werden sollen (z.B. pdf,xls,doc). Eine leere Liste bedeutet, dass alle Dateien privat sind.", "resize-image-width-threshold": "Bilder zu einer bestimmten Breite runterskalieren wenn sie breiter sind als die angegebene Breite.", diff --git a/public/language/de/user.json b/public/language/de/user.json index 56e77395ae..562a0878e1 100644 --- a/public/language/de/user.json +++ b/public/language/de/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Dieser Benutzer ignoriert bisher keine Themen.", "has_no_upvoted_posts": "Dieser Benutzer hat bisher keine Beiträge positiv bewertet.", "has_no_downvoted_posts": "Dieser Benutzer hat bisher keine Beiträge negativ bewertet.", - "has_no_voted_posts": "Dieser Benutzer hat keine bewerteten Beiträge.", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Du hast keine Benutzer geblockt", "email_hidden": "E-Mail Adresse versteckt", "hidden": "versteckt", diff --git a/public/language/el/admin/settings/uploads.json b/public/language/el/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/el/admin/settings/uploads.json +++ b/public/language/el/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/el/user.json b/public/language/el/user.json index 75000d5dd9..d68724485e 100644 --- a/public/language/el/user.json +++ b/public/language/el/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "This user hasn't upvoted any posts yet.", "has_no_downvoted_posts": "This user hasn't downvoted any posts yet.", - "has_no_voted_posts": "This user has no voted posts", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Κρυμμένο Emai", "hidden": "κρυμμένο", diff --git a/public/language/en-GB/admin/settings/uploads.json b/public/language/en-GB/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/en-GB/admin/settings/uploads.json +++ b/public/language/en-GB/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/en-GB/user.json b/public/language/en-GB/user.json index ae12d79fb5..20f47a6d95 100644 --- a/public/language/en-GB/user.json +++ b/public/language/en-GB/user.json @@ -112,7 +112,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "This user hasn't upvoted any posts yet.", "has_no_downvoted_posts": "This user hasn't downvoted any posts yet.", - "has_no_voted_posts": "This user has no voted posts", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Email Hidden", diff --git a/public/language/en-US/admin/settings/uploads.json b/public/language/en-US/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/en-US/admin/settings/uploads.json +++ b/public/language/en-US/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/en-US/user.json b/public/language/en-US/user.json index ea39bc5f81..a4d58652e8 100644 --- a/public/language/en-US/user.json +++ b/public/language/en-US/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "This user hasn't upvoted any posts yet.", "has_no_downvoted_posts": "This user hasn't downvoted any posts yet.", - "has_no_voted_posts": "This user has no voted posts", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Email Hidden", "hidden": "hidden", diff --git a/public/language/en-x-pirate/admin/settings/uploads.json b/public/language/en-x-pirate/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/en-x-pirate/admin/settings/uploads.json +++ b/public/language/en-x-pirate/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/en-x-pirate/user.json b/public/language/en-x-pirate/user.json index 06445d22ef..f23a919cfc 100644 --- a/public/language/en-x-pirate/user.json +++ b/public/language/en-x-pirate/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "This user hasn't upvoted any posts yet.", "has_no_downvoted_posts": "This user hasn't downvoted any posts yet.", - "has_no_voted_posts": "This user has no voted posts", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Email Hidden", "hidden": "hidden", diff --git a/public/language/es/admin/settings/uploads.json b/public/language/es/admin/settings/uploads.json index d8b6be6ad1..92f61f53c1 100644 --- a/public/language/es/admin/settings/uploads.json +++ b/public/language/es/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Mensajes", "private": "Hacer las subidas de archivos privadas", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "Extensiones de archivo para hacer privadas.", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Cambiar el tamaño de las imágenes si son más anchas que el ancho especificado", diff --git a/public/language/es/user.json b/public/language/es/user.json index b3fcb73fca..ecea849853 100644 --- a/public/language/es/user.json +++ b/public/language/es/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Este usuario no ha ignorado ningún tema aun.", "has_no_upvoted_posts": "Este usuario todavía no ha votado ninguna publicación positivamente.", "has_no_downvoted_posts": "Este usuario todavía no ha votado ninguna publicación negativamente.", - "has_no_voted_posts": "Este usuario no ha votado ninguna publicación", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "No tienes usuarios bloqueados.", "email_hidden": "Correo electrónico oculto", "hidden": "oculto", diff --git a/public/language/et/admin/settings/uploads.json b/public/language/et/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/et/admin/settings/uploads.json +++ b/public/language/et/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/et/user.json b/public/language/et/user.json index 1300fa1ba7..bcf307ab66 100644 --- a/public/language/et/user.json +++ b/public/language/et/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "Antud kasutaja pole veel ühtegi postitust kiitnud.", "has_no_downvoted_posts": "Antud kasutaja pole veel ühtegi postitust laitnud.", - "has_no_voted_posts": "Antud kasutaja pole veel ühtegi postitust hinnanud.", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Peidetud email", "hidden": "peidetud", diff --git a/public/language/fa-IR/admin/settings/uploads.json b/public/language/fa-IR/admin/settings/uploads.json index 2f522d6bee..dd93a43bc3 100644 --- a/public/language/fa-IR/admin/settings/uploads.json +++ b/public/language/fa-IR/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "پست‌ها", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/fa-IR/user.json b/public/language/fa-IR/user.json index 8ad132cfa5..e37cf8e4d3 100644 --- a/public/language/fa-IR/user.json +++ b/public/language/fa-IR/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "این کاربر هیچ موضوعی را نادیده نگرفته است", "has_no_upvoted_posts": "این کاربر به هیچ پستی امتیاز نداده است.", "has_no_downvoted_posts": "این کاربر به هیچ پستی رای منفی نداده است.", - "has_no_voted_posts": "این کاربر به پست رای نداده است", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "شما هیچ کاربر مسدود شده ای ندارید.", "email_hidden": "ایمیل پنهان شده", "hidden": "پنهان", diff --git a/public/language/fi/admin/settings/uploads.json b/public/language/fi/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/fi/admin/settings/uploads.json +++ b/public/language/fi/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/fi/user.json b/public/language/fi/user.json index f8535f3061..b61fa0594b 100644 --- a/public/language/fi/user.json +++ b/public/language/fi/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Käyttäjä ei ole merkannut sivuutettavaksi yhtään aihetta.", "has_no_upvoted_posts": "Käyttäjä ei ole tykännyt yhdestäkään viestistä vielä.", "has_no_downvoted_posts": "This user hasn't downvoted any posts yet.", - "has_no_voted_posts": "Käyttäjä ei ole antanut tykkäyksiä viesteille", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Käyttäjä ei ole estänyt käyttäjiä", "email_hidden": "Sähköposti piilotettu", "hidden": "piilotettu", diff --git a/public/language/fr/admin/settings/uploads.json b/public/language/fr/admin/settings/uploads.json index 1e4cf45bb4..dcf1b83dd8 100644 --- a/public/language/fr/admin/settings/uploads.json +++ b/public/language/fr/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Sujets", "private": "Rendre privés les fichiers téléchargés", "strip-exif-data": "Supprimer les données EXIF", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "Rendre privé des extensions de fichier.", "private-uploads-extensions-help": "Renseignez ici une liste d'extensions de fichiers séparées par des virgules pour les rendre privées (par exemple : pdf, xls, doc). Une liste vide signifie que tous les fichiers sont privés.", "resize-image-width-threshold": "Redimensionner les images si elles sont plus larges que la largeur spécifiée", diff --git a/public/language/fr/user.json b/public/language/fr/user.json index 7540c0da6a..14a455e9e2 100644 --- a/public/language/fr/user.json +++ b/public/language/fr/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Cet utilisateur n'a encore ignoré aucun sujet.", "has_no_upvoted_posts": "Cet utilisateur n'a donné d'avis positifs", "has_no_downvoted_posts": "Cet utilisateur n'a pas donné d'avis négatifs", - "has_no_voted_posts": "Personne n'a voté pour des messages de cet utilisateur", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Vous n'avez bloqué aucun utilisateur.", "email_hidden": "Email masqué", "hidden": "masqué", diff --git a/public/language/gl/admin/settings/uploads.json b/public/language/gl/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/gl/admin/settings/uploads.json +++ b/public/language/gl/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/gl/user.json b/public/language/gl/user.json index 3a3e4ae7c8..d5fed29394 100644 --- a/public/language/gl/user.json +++ b/public/language/gl/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "Este usuario aínda non votou positivamente ningunha mensaxe.", "has_no_downvoted_posts": "Este usuario aínda non votou negativamente ninguna mensaxe.", - "has_no_voted_posts": "Este usuario non votou ninguna mensaxe", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Correo Agochado", "hidden": "Agochado", diff --git a/public/language/he/admin/settings/post.json b/public/language/he/admin/settings/post.json index e2c3681728..ec29d28800 100644 --- a/public/language/he/admin/settings/post.json +++ b/public/language/he/admin/settings/post.json @@ -55,11 +55,11 @@ "composer": "הגדרות יצירת פוסט", "composer-help": "ההגדרות הבאות חלות על הפונקציונליות ו/או המראה של יוצר הפוסט המוצג\n\t\t\t\tלמשתמשים בעת יצירת נושאים חדשים, או מענה לנושאים קיימים.", "composer.show-help": "הצג כרטיסיית \"עזרה\"", - "composer.enable-plugin-help": "אפשר לתוסיפים להוסיף תוכן ללשונית עזרה", + "composer.enable-plugin-help": "אפשר לתוספים להוסיף תוכן ללשונית עזרה", "composer.custom-help": "טקסט עזרה מותאם אישית", - "backlinks": "Backlinks", - "backlinks.enabled": "Enable topic backlinks", - "backlinks.help": "If a post references another topic, a link back to the post will be inserted into the referenced topic at that point in time.", + "backlinks": "קישורים נכנסים", + "backlinks.enabled": "אפשר קישורים נכנסים בנושא", + "backlinks.help": "אם פוסט מפנה לנושא אחר, קישור חזרה לפוסט יתווסף לנושא אליו בוצעה ההפניה בשלב זה.", "ip-tracking": "IP מעקב", "ip-tracking.each-post": "מעקב אחר כתובת IP על כל הודעה", "enable-post-history": "הפוך היסטוריית פוסטים לזמינה" diff --git a/public/language/he/admin/settings/reputation.json b/public/language/he/admin/settings/reputation.json index be3d7a773f..69f78b0cc0 100644 --- a/public/language/he/admin/settings/reputation.json +++ b/public/language/he/admin/settings/reputation.json @@ -18,5 +18,5 @@ "flags.limit-per-target": "מספר הפעמים המרבי שניתן לסמן משהו", "flags.limit-per-target-placeholder": "ברירת מחדל: 0", "flags.limit-per-target-help": "כשפוסט או משתמש מסומן כמה פעמים, כל דיווח נוסף נחשב ל "דיווח" ונוסף לדיווח הראשון. הגדר את האופציה הזאת לכל מספר שהוא לא 0 כדי להגביל את כמות הדיווחים שפוסט או משתמש יכול לקבל.", - "flags.auto-resolve-on-ban": "Automatically resolve all of a user's tickets when they are banned" + "flags.auto-resolve-on-ban": "פתור אוטומטי כל כרטיסי משתמש כאשר הוא מוחרם" } \ No newline at end of file diff --git a/public/language/he/admin/settings/uploads.json b/public/language/he/admin/settings/uploads.json index e65ff45be4..dda268c4cc 100644 --- a/public/language/he/admin/settings/uploads.json +++ b/public/language/he/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "פוסטים", "private": "הפוך קבצים שהועלו לפרטיים", "strip-exif-data": "הפשט נתוני EXIF", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "סיומות קובצים להפוך לפרטיים", "private-uploads-extensions-help": "הכנס כאן רשימה של פורמטי הקבצים, מופרדים בפסיק, כדי להפוך אותם לפרטיים (לדוגמא pdf,xls,doc). שורה ריקה פירושו שכל הקבצים פרטיים.", "resize-image-width-threshold": "שנה גודל תמונות אם הם רחבים יותר מהרוחב המוגדר", diff --git a/public/language/he/global.json b/public/language/he/global.json index 6efda815bb..5ca8d625b3 100644 --- a/public/language/he/global.json +++ b/public/language/he/global.json @@ -56,7 +56,7 @@ "posts": "פוסטים", "x-posts": "%1 פוסטים", "best": "הגבוה ביותר", - "controversial": "Controversial", + "controversial": "שנוי במחלוקת", "votes": "הצבעות", "x-votes": "%1 הצבעות", "voters": "מצביעים", diff --git a/public/language/he/pages.json b/public/language/he/pages.json index 0ef1af98b9..778ce89772 100644 --- a/public/language/he/pages.json +++ b/public/language/he/pages.json @@ -54,7 +54,7 @@ "account/upvoted": "פוסטים שהוצבעו לטובה על ידי %1", "account/downvoted": "פוסטים שהוצבעו לרעה על ידי %1", "account/best": "הפוסטים הטובים ביותר שנוצרו על ידי %1", - "account/controversial": "Controversial posts made by %1", + "account/controversial": "פוסטים השנויים במחלוקת שנוצרו על ידי %1", "account/blocks": "המשתמשים ש-%1 חסם", "account/uploads": "העלאות של %1", "account/sessions": "סשני התחברות", diff --git a/public/language/he/user.json b/public/language/he/user.json index d309524fc8..bff782998a 100644 --- a/public/language/he/user.json +++ b/public/language/he/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "המשתמש הזה טרם התעלם מנושאים.", "has_no_upvoted_posts": "המשתמש טרם הצביע בעד פוסטים כלשהם.", "has_no_downvoted_posts": "המשתמש טרם הצביע נגד פוסטים כלשהם.", - "has_no_voted_posts": "למשתמש אין פוסטים שהוצבעו", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "לא חסמת אף משתמש.", "email_hidden": "כתובת אימייל מוסתרת", "hidden": "מוסתר", diff --git a/public/language/hr/admin/settings/uploads.json b/public/language/hr/admin/settings/uploads.json index f4e8159f13..f405581844 100644 --- a/public/language/hr/admin/settings/uploads.json +++ b/public/language/hr/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Objave", "private": "Učini datoteke privatnim", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/hr/user.json b/public/language/hr/user.json index 6d5ff5a167..4b6527dd86 100644 --- a/public/language/hr/user.json +++ b/public/language/hr/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "Ovaj korisnik nije glasao za na objavama.", "has_no_downvoted_posts": "Ovaj korisnik nije glasao protiv na objavama.", - "has_no_voted_posts": "Ovaj korisnik nema glasanih objava", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Email sakriven", "hidden": "Sakriven", diff --git a/public/language/hu/admin/settings/uploads.json b/public/language/hu/admin/settings/uploads.json index 4fa5a4c59a..394bd8d66b 100644 --- a/public/language/hu/admin/settings/uploads.json +++ b/public/language/hu/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Hozzászólások", "private": "Feltöltött fájlok priváttá tevése", "strip-exif-data": "EXIF adatok törlése", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "Privát kiterjesztések", "private-uploads-extensions-help": "Add meg vesszővel elválasztva a privát kiterjesztések listáját (pl.: pdf,xls,doc) Az üres lista azt jelenti, hogy minden fájl privát.", "resize-image-width-threshold": "Képek átméretezése, ha szélesebbek, mint a megadott szélesség", diff --git a/public/language/hu/user.json b/public/language/hu/user.json index ccf0a08716..49408785bf 100644 --- a/public/language/hu/user.json +++ b/public/language/hu/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "A felhasználó még nem mellőzött témakört.", "has_no_upvoted_posts": "A felhasználó még egy hozzászólást sem kedvelt.", "has_no_downvoted_posts": "A felhasználó még egy hozzászólást sem utált.", - "has_no_voted_posts": "A felhasználó még nem szavazott hozzászólásra.", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Nem blokkoltál egy felhasználót sem.", "email_hidden": "E-mail rejtett", "hidden": "rejtett", diff --git a/public/language/id/admin/settings/uploads.json b/public/language/id/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/id/admin/settings/uploads.json +++ b/public/language/id/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/id/user.json b/public/language/id/user.json index 27520b0131..c8e76c9527 100644 --- a/public/language/id/user.json +++ b/public/language/id/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "This user hasn't upvoted any posts yet.", "has_no_downvoted_posts": "This user hasn't downvoted any posts yet.", - "has_no_voted_posts": "This user has no voted posts", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Email Disembunyikan", "hidden": "disembunyikan", diff --git a/public/language/it/admin/settings/uploads.json b/public/language/it/admin/settings/uploads.json index aa2b3e219b..04bf6709ad 100644 --- a/public/language/it/admin/settings/uploads.json +++ b/public/language/it/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Rendi privati i file caricati", "strip-exif-data": "Togli EXIF Data", + "preserve-orphaned-uploads": "Mantieni i file caricati su disco dopo l'eliminazione di un post", "private-extensions": "Estensione dei file da rendere privata", "private-uploads-extensions-help": "Inserisci la lista di estensioni separati da virgola quì (es. pdf,xls,doc). Una lista vuota significa che tutti i file sono privati.", "resize-image-width-threshold": "Ridimensiona le immagini se sono più grandi della larghezza specificata", diff --git a/public/language/it/user.json b/public/language/it/user.json index a341aad151..beba3efbf3 100644 --- a/public/language/it/user.json +++ b/public/language/it/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Questo utente non sta ignorando discussioni.", "has_no_upvoted_posts": "Questo utente non ha ancora apprezzato nessun post.", "has_no_downvoted_posts": "Questo utente non ha ancora votato negativamente alcun post", - "has_no_voted_posts": "Questo utente non ha post votati", + "has_no_controversial_posts": "Questo utente non ha ancora nessun post votato negativamente.", "has_no_blocks": "Non hai bloccato utenti.", "email_hidden": "Email Nascosta", "hidden": "nascosta", diff --git a/public/language/ja/admin/settings/uploads.json b/public/language/ja/admin/settings/uploads.json index 2f5f5dc55b..55e6da3bfd 100644 --- a/public/language/ja/admin/settings/uploads.json +++ b/public/language/ja/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "投稿", "private": "アップロードしたファイルを非公開にする", "strip-exif-data": "EXIFデータを削除", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "非公開にするファイル拡張子", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "指定した幅より広い場合は画像のサイズを変更します", diff --git a/public/language/ja/user.json b/public/language/ja/user.json index b01bc659f4..4509e672c8 100644 --- a/public/language/ja/user.json +++ b/public/language/ja/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "この利用者はまだトピックを無視していません。", "has_no_upvoted_posts": "このユーザーはまだ一つも投稿に高評価を付けていません。", "has_no_downvoted_posts": "このユーザーはまだ一つも投稿に低評価を付けていません。", - "has_no_voted_posts": "このユーザーは投稿を評価していません。", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "ブロック中のユーザーはいません。", "email_hidden": "メールアドレスを非表示", "hidden": "非表示", diff --git a/public/language/ko/admin/settings/uploads.json b/public/language/ko/admin/settings/uploads.json index de9f9d4356..52dee41905 100644 --- a/public/language/ko/admin/settings/uploads.json +++ b/public/language/ko/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "포스트", "private": "가입된 사용자만 파일 열람 허용", "strip-exif-data": "이미지 EXIF 데이터 제거", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "파일 확장자 숨김", "private-uploads-extensions-help": "비공개로 설정할 파일 확장자 목록을 쉼표로 구분해서 입력하세요. (예: pdf, xls, doc). 빈 목록은 모든 파일이 비공개임을 의미합니다.", "resize-image-width-threshold": "설정한 너비보다 넓은 이미지의 크기 조정", diff --git a/public/language/ko/user.json b/public/language/ko/user.json index e633a22205..28fc86680d 100644 --- a/public/language/ko/user.json +++ b/public/language/ko/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "이 사용자는 아직 무시 중인 화제가 없습니다.", "has_no_upvoted_posts": "이 사용자가 추천한 포스트가 없습니다.", "has_no_downvoted_posts": "이 사용자가 비추천한 포스트가 없습니다.", - "has_no_voted_posts": "이 사용자가 투표를 받은 게시물이 없습니다.", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "차단한 사용자가 없습니다.", "email_hidden": "이메일 비공개", "hidden": "비공개", diff --git a/public/language/lt/admin/settings/uploads.json b/public/language/lt/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/lt/admin/settings/uploads.json +++ b/public/language/lt/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/lt/user.json b/public/language/lt/user.json index 1005308f43..481c5bfdf5 100644 --- a/public/language/lt/user.json +++ b/public/language/lt/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "Šis narys dar neturi teigiamai įvertintų pranešimų.", "has_no_downvoted_posts": "Šis narys dar neturi neigiamai įvertintų pranešimų.", - "has_no_voted_posts": "Šis narys dar neturi įvertintų pranešimų.", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "El. paštas paslėptas", "hidden": "paslėptas", diff --git a/public/language/lv/admin/settings/uploads.json b/public/language/lv/admin/settings/uploads.json index 2b8c4f0122..ef84cf80f8 100644 --- a/public/language/lv/admin/settings/uploads.json +++ b/public/language/lv/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Raksti", "private": "Iestatīt augšupielādētos failus kā privātus", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "Failu paplašīnājumi, kurus turēt privātus", "private-uploads-extensions-help": "Ievadīt ar komatu atdalītu failu paplašinājumu sarakstu, kurus turēt privātus (piemērām pdf,xls,doc). Tukšais saraksts nozīmē, ka visi faili ir privāti.", "resize-image-width-threshold": "Samazināt blides izmērus, ja ir plašāka par noteikto platumu", diff --git a/public/language/lv/user.json b/public/language/lv/user.json index c390bbb2f0..5308e64362 100644 --- a/public/language/lv/user.json +++ b/public/language/lv/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Lietotājs nav vēl ignorējis nevienu tematu.", "has_no_upvoted_posts": "Lietotājs vēl nav balsojis \"par\" nevienu rakstu.", "has_no_downvoted_posts": "Lietotājs vēl nav balsojis \"pret\" nevienu rakstu.", - "has_no_voted_posts": "Lietotājam nav nevienu rakstu ar balsojumiem.", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Tu neesi bloķējis nevienu lietotāju.", "email_hidden": "E-pasta adrese paslēpta", "hidden": "paslēpies", diff --git a/public/language/ms/admin/settings/uploads.json b/public/language/ms/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/ms/admin/settings/uploads.json +++ b/public/language/ms/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/ms/user.json b/public/language/ms/user.json index 36c7af0a3a..4f7a5b9dec 100644 --- a/public/language/ms/user.json +++ b/public/language/ms/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "This user hasn't upvoted any posts yet.", "has_no_downvoted_posts": "This user hasn't downvoted any posts yet.", - "has_no_voted_posts": "This user has no voted posts", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Emel disembunyikan", "hidden": "disembunyikan", diff --git a/public/language/nb/admin/settings/uploads.json b/public/language/nb/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/nb/admin/settings/uploads.json +++ b/public/language/nb/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/nb/user.json b/public/language/nb/user.json index 3841ff4918..1e0f3fcf9f 100644 --- a/public/language/nb/user.json +++ b/public/language/nb/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Denne brukeren har ikke ignorert noen emner ennå", "has_no_upvoted_posts": "Denne brukeren har ikke lastet opp noen innlegg ennå", "has_no_downvoted_posts": "Denne brukeren har ikke nedlastet noen innlegg ennå", - "has_no_voted_posts": "Denne brukeren har ingen innlegg som er stemt på", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Du har ingen blokkerte brukere.", "email_hidden": "E-post skjult", "hidden": "skjult", diff --git a/public/language/nl/admin/settings/uploads.json b/public/language/nl/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/nl/admin/settings/uploads.json +++ b/public/language/nl/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/nl/user.json b/public/language/nl/user.json index d1ce88b2e9..b5f9e10232 100644 --- a/public/language/nl/user.json +++ b/public/language/nl/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Deze gebruiker heeft nog geen berichten genegeerd.", "has_no_upvoted_posts": "Deze gebruiker heeft nog geen berichten omhoog gestemd.", "has_no_downvoted_posts": "Deze gebruiker heeft nog geen berichten omlaag gestemd.", - "has_no_voted_posts": "Deze gebruiker heeft nog niet op berichten gestemd", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "U hebt geen gebruikers geblokkeerd", "email_hidden": "E-mail niet beschikbaar", "hidden": "verborgen", diff --git a/public/language/pl/admin/settings/uploads.json b/public/language/pl/admin/settings/uploads.json index 0cb0bd80e5..4b3d9d6f12 100644 --- a/public/language/pl/admin/settings/uploads.json +++ b/public/language/pl/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posty", "private": "Oznaczaj wysyłane pliki jako prywatne", "strip-exif-data": "Usuń dane EXIF", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "Rozszerzenia plików, które mają być prywatne", "private-uploads-extensions-help": "Tutaj wpisz oddzielone przecinkami rozszerzenia plików, które mają być prywatne (np. pdf,xls,doc). Jeśli lista jest pusta, wszystkie pliki są prywatne.", "resize-image-width-threshold": "Zmień rozmiar obrazów, jeśli są szersze niż określona szerokość", diff --git a/public/language/pl/user.json b/public/language/pl/user.json index fe9d265ed7..3d088c2bd2 100644 --- a/public/language/pl/user.json +++ b/public/language/pl/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Użytkownik nie pominął jeszcze żadnego tematu.", "has_no_upvoted_posts": "Ten użytkownik jeszcze nie głosował za w żadnym temacie", "has_no_downvoted_posts": "Ten użytkownik jeszcze nie głosował przeciw w żadnym temacie.", - "has_no_voted_posts": "Ten użytkownik nie ma jeszcze ocenionych postów", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Nie zablokowałeś jeszcze żadnych użytkowników", "email_hidden": "Adres e-mail ukryty", "hidden": "ukryty", diff --git a/public/language/pt-BR/admin/settings/uploads.json b/public/language/pt-BR/admin/settings/uploads.json index 4a939df66e..cd696346dc 100644 --- a/public/language/pt-BR/admin/settings/uploads.json +++ b/public/language/pt-BR/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Tornar arquivos enviados particulares", "strip-exif-data": "Retirar Metadata EXIF", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "Extensões de arquivo para tornar privado", "private-uploads-extensions-help": "Digite uma lista, separada por vírgulas, de extensões de arquivos para torná-las privadas aqui (por exemplo: pdf, xls, doc). Uma lista vazia sinigica que todos os arquivos são privado.", "resize-image-width-threshold": "Redimensionar imagens se a largura dela for maior do que a largura especificada", diff --git a/public/language/pt-BR/user.json b/public/language/pt-BR/user.json index 13763daa85..6b3ab8a029 100644 --- a/public/language/pt-BR/user.json +++ b/public/language/pt-BR/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "O usuário ainda não ignorou nenhum tópico.", "has_no_upvoted_posts": "Este usuário ainda não votou positivamente em quaisquer posts.", "has_no_downvoted_posts": "Este usuário ainda não votou negativamente em quaisquer posts.", - "has_no_voted_posts": "Este usuário não tem posts votados", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Você não bloqueou nenhum usuário.", "email_hidden": "E-mail Oculto", "hidden": "oculto", diff --git a/public/language/pt-PT/admin/settings/uploads.json b/public/language/pt-PT/admin/settings/uploads.json index 3bf6347362..b229f0a860 100644 --- a/public/language/pt-PT/admin/settings/uploads.json +++ b/public/language/pt-PT/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Publicações", "private": "Tornar os ficheiros enviados privados", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/pt-PT/user.json b/public/language/pt-PT/user.json index 9c839dc474..b8721b81d2 100644 --- a/public/language/pt-PT/user.json +++ b/public/language/pt-PT/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Este utilizador ainda não ignorou nenhum tópico.", "has_no_upvoted_posts": "Este utilizador ainda não votou favoravelmente em nenhuma publicação.", "has_no_downvoted_posts": "Este utilizador ainda não votou negativamente em nenhuma publicação.", - "has_no_voted_posts": "Este utilizador ainda não tem nenhuma publicação com votos", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Não bloqueaste nenhum utilizador.", "email_hidden": "E-mail escondido", "hidden": "Escondido", diff --git a/public/language/ro/admin/settings/uploads.json b/public/language/ro/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/ro/admin/settings/uploads.json +++ b/public/language/ro/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/ro/user.json b/public/language/ro/user.json index 449a4eb3e3..4c2d37d421 100644 --- a/public/language/ro/user.json +++ b/public/language/ro/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "This user hasn't upvoted any posts yet.", "has_no_downvoted_posts": "This user hasn't downvoted any posts yet.", - "has_no_voted_posts": "This user has no voted posts", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Adresă de email ascunsă", "hidden": "ascuns", diff --git a/public/language/ru/admin/settings/uploads.json b/public/language/ru/admin/settings/uploads.json index 7c0e1a03f1..4942c814d2 100644 --- a/public/language/ru/admin/settings/uploads.json +++ b/public/language/ru/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Сообщения", "private": "Не показывать загрузки гостям", "strip-exif-data": "Удалять метаданные EXIF", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "Типы файлов, которые следует скрывать от гостей", "private-uploads-extensions-help": "Укажите через запятую список расширений файлов, например pdf,xls,doc. Оставьте поле пустым, чтобы все загрузки были недоступны гостям.", "resize-image-width-threshold": "Уменьшать изображения, когда ширина превышает", diff --git a/public/language/ru/user.json b/public/language/ru/user.json index 7b5b02302a..09fb1a7d75 100644 --- a/public/language/ru/user.json +++ b/public/language/ru/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Этот пользователь не игнорирует ни одну тему.", "has_no_upvoted_posts": "Этот пользователь ещё ни одному сообщению не поднимал рейтинг.", "has_no_downvoted_posts": "Этот пользователь ещё ни одному сообщению не понижал рейтинг.", - "has_no_voted_posts": "За сообщения этого пользователя ещё не голосовали", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Вы никого не заблокировали.", "email_hidden": "Электронная почта скрыта", "hidden": "скрыто", diff --git a/public/language/rw/admin/settings/uploads.json b/public/language/rw/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/rw/admin/settings/uploads.json +++ b/public/language/rw/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/rw/user.json b/public/language/rw/user.json index e98238c754..b5a94aab34 100644 --- a/public/language/rw/user.json +++ b/public/language/rw/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "Uyu muntu ntabwo arashima icyashyizweho na kimwe.", "has_no_downvoted_posts": "Uyu muntu ntabwo aragaya icyashizweho na kimwe. ", - "has_no_voted_posts": "Uyu muntu ntabwo aragira ikintu yashimiwe gushyiraho", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Email Yahishwe", "hidden": "byahishwe", diff --git a/public/language/sc/admin/settings/uploads.json b/public/language/sc/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/sc/admin/settings/uploads.json +++ b/public/language/sc/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/sc/user.json b/public/language/sc/user.json index b515653b95..7a3db319ff 100644 --- a/public/language/sc/user.json +++ b/public/language/sc/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "This user hasn't ignored any topics yet.", "has_no_upvoted_posts": "This user hasn't upvoted any posts yet.", "has_no_downvoted_posts": "This user hasn't downvoted any posts yet.", - "has_no_voted_posts": "This user has no voted posts", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "Email Cuada", "hidden": "cuadu", diff --git a/public/language/sk/admin/settings/uploads.json b/public/language/sk/admin/settings/uploads.json index 1ce51400d0..e85a7f6359 100644 --- a/public/language/sk/admin/settings/uploads.json +++ b/public/language/sk/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Príspevky", "private": "Nahrané súbory sú súkromné", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "Prípona súborov je súkromná", "private-uploads-extensions-help": "Pre nastavenie súkromia, zadajte sem zoznam súborov oddelených čiarkou (napr.: pdf,xls,doc). Prázdny zoznam znamená, že všetky súbory sú súkromné.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/sk/user.json b/public/language/sk/user.json index f735736382..e5870ffdf8 100644 --- a/public/language/sk/user.json +++ b/public/language/sk/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Tento používateľ neignoruje žiadne témy.", "has_no_upvoted_posts": "Tento užívateľ doteraz nedal hlas žiadnemu príspevku.", "has_no_downvoted_posts": "Tento užívateľ doteraz neodobral hlas žiadnemu príspevku.", - "has_no_voted_posts": "Tento užívateľ nemá žiadne príspevky s hlasmi", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Nezablokoval ste žiadneho používateľa.", "email_hidden": "Skrytý e-mail", "hidden": "skrytý", diff --git a/public/language/sl/admin/settings/uploads.json b/public/language/sl/admin/settings/uploads.json index b515e07ab3..152add3366 100644 --- a/public/language/sl/admin/settings/uploads.json +++ b/public/language/sl/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Objave", "private": "Naložene datoteke označi kot zasebne", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/sl/user.json b/public/language/sl/user.json index fa11a8ef07..edfb2f1f3a 100644 --- a/public/language/sl/user.json +++ b/public/language/sl/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Ta uporabnik še nima nobenih prezrtih tem.", "has_no_upvoted_posts": "Uporabnik še ni glasoval za nobeno objavo.", "has_no_downvoted_posts": "Uporabnik še ni glasoval proti nobeni objavi.", - "has_no_voted_posts": "Uporabnik nima glasovanih objav", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Nimate blokiranih uporabnikov.", "email_hidden": "Skrit e-poštni naslov", "hidden": "skrit", diff --git a/public/language/sr/admin/settings/uploads.json b/public/language/sr/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/sr/admin/settings/uploads.json +++ b/public/language/sr/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/sr/user.json b/public/language/sr/user.json index 955a6dee96..1920e715d4 100644 --- a/public/language/sr/user.json +++ b/public/language/sr/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Овај корисник још није игнорисао ниједну тему.", "has_no_upvoted_posts": "Овај корисник још увек није гласао за неку поруку.", "has_no_downvoted_posts": "Овај корисник још увек није негативно гласао за неку поруку.", - "has_no_voted_posts": "Овај корисник нема објаве за које се гласало.", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Нисте блокирали ниједног корисника", "email_hidden": "Скривена е-пошта", "hidden": "скривена", diff --git a/public/language/sv/admin/settings/uploads.json b/public/language/sv/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/sv/admin/settings/uploads.json +++ b/public/language/sv/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/sv/user.json b/public/language/sv/user.json index aff37eb840..aaa13bfcd5 100644 --- a/public/language/sv/user.json +++ b/public/language/sv/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Denna användare ignorerar inte några ämnen ännu.", "has_no_upvoted_posts": "Den här användaren har inte röstat upp några inlägg än.", "has_no_downvoted_posts": "Den här användaren har inte röstat ned några inlägg än.", - "has_no_voted_posts": "Den här användaren har inga inlägg med röster", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Du har inte blockerat några användare.", "email_hidden": "E-post dold", "hidden": "dold", diff --git a/public/language/th/admin/settings/uploads.json b/public/language/th/admin/settings/uploads.json index ba9d012d87..af99a3ae77 100644 --- a/public/language/th/admin/settings/uploads.json +++ b/public/language/th/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Posts", "private": "Make uploaded files private", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/th/user.json b/public/language/th/user.json index a805b0d908..9e14cb770d 100644 --- a/public/language/th/user.json +++ b/public/language/th/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "ผู้ใช้นี้ไม่ได้ละเว้นกระทู้ใดๆ", "has_no_upvoted_posts": "ผู้ใช้นี้ไม่ได้โหวตขึ้นให้ข้อความใดๆ", "has_no_downvoted_posts": "ผู้ใช้นี้ไม่ได้โหวตลงให้ข้อความใดๆ", - "has_no_voted_posts": "ผู้ใช้นี้ไม่เคยโหวตข้อความ", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "You have blocked no users.", "email_hidden": "ซ่อนอีเมล", "hidden": "ซ่อน", diff --git a/public/language/tr/admin/settings/uploads.json b/public/language/tr/admin/settings/uploads.json index 6ced78250c..b5f75b4b3f 100644 --- a/public/language/tr/admin/settings/uploads.json +++ b/public/language/tr/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "İletiler", "private": "Yüklenen dosyaları gizli yap", "strip-exif-data": "EXIF bilgilerini sil", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "Gizli yapılacak dosya uzantıları", "private-uploads-extensions-help": "Buraya gizli yapılacak dosya uzantıları listesini virgülle ayırarak giriniz. (ör. pdf,xls,doc). Boş bırakmak, tüm dosyaların gizli olacağı anlamına gelir.", "resize-image-width-threshold": "Belirtilen genişlikten daha genişse görüntüleri yeniden boyutlandırın", diff --git a/public/language/tr/global.json b/public/language/tr/global.json index 981d75bb62..1e0e42dce3 100644 --- a/public/language/tr/global.json +++ b/public/language/tr/global.json @@ -56,7 +56,7 @@ "posts": "İleti", "x-posts": "%1 ileti", "best": "En İyi", - "controversial": "Kontrollü", + "controversial": "Tartışmalı", "votes": "Oy", "x-votes": "%1 oy", "voters": "Oy Verenler", diff --git a/public/language/tr/user.json b/public/language/tr/user.json index 687ab01032..1ff04a6a4d 100644 --- a/public/language/tr/user.json +++ b/public/language/tr/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Bu kullanıcı henüz hiçbir başlığı yok saymamış.", "has_no_upvoted_posts": "Bu kullanıcı henüz hiçbir iletiyi artılamamış.", "has_no_downvoted_posts": "Bu kullanıcı henüz hiçbir iletiyi eksilememiş.", - "has_no_voted_posts": "Bu kullanıcının hiç oylanmış iletisi yok.", + "has_no_controversial_posts": "Bu kullanıcının herhangi bir gönderisi eksi oy almadı.", "has_no_blocks": "Hiçbir kullanıcıyı engellemediniz.", "email_hidden": "E-posta gizli", "hidden": "gizli", diff --git a/public/language/uk/admin/settings/uploads.json b/public/language/uk/admin/settings/uploads.json index bee1eaab44..46443d2a5a 100644 --- a/public/language/uk/admin/settings/uploads.json +++ b/public/language/uk/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Пости", "private": "Зробити завантажувані файли приватними", "strip-exif-data": "Strip EXIF Data", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "File extensions to make private", "private-uploads-extensions-help": "Enter comma-separated list of file extensions to make private here (e.g. pdf,xls,doc). An empty list means all files are private.", "resize-image-width-threshold": "Resize images if they are wider than specified width", diff --git a/public/language/uk/user.json b/public/language/uk/user.json index 47d65b06f0..78d857e7c0 100644 --- a/public/language/uk/user.json +++ b/public/language/uk/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Цей користувач ще не проігнорував будь-які теми.", "has_no_upvoted_posts": "Цей користувач ще не голосував за жоден з постів.", "has_no_downvoted_posts": "Цей користувач ще не голосував проти жодного поста.", - "has_no_voted_posts": "У цього користувача немає постів за котрі хтось голосував", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "Ви нікого не заблокували.", "email_hidden": "Електронна адреса прихована", "hidden": "прихований", diff --git a/public/language/vi/admin/settings/uploads.json b/public/language/vi/admin/settings/uploads.json index cc0967044f..f22d0b3b53 100644 --- a/public/language/vi/admin/settings/uploads.json +++ b/public/language/vi/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "Bài Đăng", "private": "Đặt tệp tải lên ở chế độ riêng tư", "strip-exif-data": "Tách Dữ Liệu EXIF", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "Phần mở rộng tệp để đặt ở chế độ riêng tư", "private-uploads-extensions-help": "Nhập danh sách phần mở rộng tệp tại đây phân tách bằng dấu phẩy để đặt ở chế độ riêng tư (VD: pdf,xls,doc). Để trống có nghĩa là mọi tệp đều riêng tư.", "resize-image-width-threshold": "Chỉnh kích cỡ ảnh nếu chúng rộng hơn chiều rộng đã đặt", diff --git a/public/language/vi/user.json b/public/language/vi/user.json index ccf52bbe49..7cd520e9af 100644 --- a/public/language/vi/user.json +++ b/public/language/vi/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "Người dùng này chưa bỏ qua bất cứ chủ đề nào.", "has_no_upvoted_posts": "Người dùng này chưa ủng hộ bất kỳ bài đăng nào.", "has_no_downvoted_posts": "Thành viên này chưa phản đối bài viết nào cả.", - "has_no_voted_posts": "Thành viên này không có bài viết nào được tán thành.", + "has_no_controversial_posts": "Người dùng này chưa có bài viết nào bị phản đối.", "has_no_blocks": "Bạn không khóa người dùng nào.", "email_hidden": "Ẩn Email", "hidden": "Đã ẩn", diff --git a/public/language/zh-CN/admin/settings/uploads.json b/public/language/zh-CN/admin/settings/uploads.json index 959da6f292..628144c933 100644 --- a/public/language/zh-CN/admin/settings/uploads.json +++ b/public/language/zh-CN/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "帖子", "private": "使上传的文件私有化", "strip-exif-data": "去除 EXIF 数据", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "自定义文件扩展名", "private-uploads-extensions-help": "在此处输入以逗号分隔的文件扩展名列表 (例如 pdf,xls,doc )并将其用于自定义。为空则表示允许所有扩展名。", "resize-image-width-threshold": "如果图像宽度超过指定大小,则对图像进行缩放", diff --git a/public/language/zh-CN/user.json b/public/language/zh-CN/user.json index 20a64ee263..a890a3c3df 100644 --- a/public/language/zh-CN/user.json +++ b/public/language/zh-CN/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "此用户尚未忽略任何主题。", "has_no_upvoted_posts": "此用户还未顶过任何帖子。", "has_no_downvoted_posts": "此用户还未踩过任何帖子。", - "has_no_voted_posts": "这个用户还未评价任何帖子", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "您没有屏蔽其他用户。", "email_hidden": "电子邮箱已隐藏", "hidden": "隐藏", diff --git a/public/language/zh-TW/admin/settings/uploads.json b/public/language/zh-TW/admin/settings/uploads.json index 9fc247a277..b005261389 100644 --- a/public/language/zh-TW/admin/settings/uploads.json +++ b/public/language/zh-TW/admin/settings/uploads.json @@ -2,6 +2,7 @@ "posts": "貼文", "private": "使上傳的檔案私有化", "strip-exif-data": "去除 EXIF 資料", + "preserve-orphaned-uploads": "Keep uploaded files on disk after a post is purged", "private-extensions": "自訂檔案附檔名", "private-uploads-extensions-help": "在此處輸入以逗號分隔的副檔名列表 (例如 pdf,xls,doc )並將其用於自訂。為空則表示允許所有副檔名。", "resize-image-width-threshold": "如果圖片寬度超過指定大小,則對圖片進行縮放", diff --git a/public/language/zh-TW/user.json b/public/language/zh-TW/user.json index 462e6d3ab0..52ddc25712 100644 --- a/public/language/zh-TW/user.json +++ b/public/language/zh-TW/user.json @@ -105,7 +105,7 @@ "has_no_ignored_topics": "此使用者尚未忽略任何主題。", "has_no_upvoted_posts": "此使用者還未點贊過任何貼文。", "has_no_downvoted_posts": "此使用者還未倒讚過任何貼文。", - "has_no_voted_posts": "這個使用者還未評價任何貼文", + "has_no_controversial_posts": "This user does not have any downvoted posts yet.", "has_no_blocks": "您沒有封鎖其他使用者。", "email_hidden": "電子信箱已隱藏", "hidden": "隱藏", diff --git a/public/src/admin/appearance/themes.js b/public/src/admin/appearance/themes.js index be91d3ad6c..36a98c6a38 100644 --- a/public/src/admin/appearance/themes.js +++ b/public/src/admin/appearance/themes.js @@ -15,6 +15,9 @@ define('admin/appearance/themes', ['bootbox', 'translator', 'alerts'], function const cssSrc = parentEl.attr('data-css'); const themeId = parentEl.attr('data-theme'); + if (config['theme:id'] === themeId) { + return; + } socket.emit('admin.themes.set', { type: themeType, id: themeId, @@ -43,6 +46,9 @@ define('admin/appearance/themes', ['bootbox', 'translator', 'alerts'], function }); $('#revert_theme').on('click', function () { + if (config['theme:id'] === 'nodebb-theme-persona') { + return; + } bootbox.confirm('[[admin/appearance/themes:revert-confirm]]', function (confirm) { if (confirm) { socket.emit('admin.themes.set', { @@ -52,6 +58,7 @@ define('admin/appearance/themes', ['bootbox', 'translator', 'alerts'], function if (err) { return alerts.error(err); } + config['theme:id'] = 'nodebb-theme-persona'; highlightSelectedTheme('nodebb-theme-persona'); alerts.alert({ alert_id: 'admin:theme', diff --git a/public/src/ajaxify.js b/public/src/ajaxify.js index 04bcb6949e..63f590bc12 100644 --- a/public/src/ajaxify.js +++ b/public/src/ajaxify.js @@ -434,7 +434,7 @@ ajaxify = window.ajaxify || {}; }; ajaxify.loadTemplate = function (template, callback) { - require([config.assetBaseUrl + '/templates/' + template + '.js'], callback, function (err) { + require([config.asset_base_url + '/templates/' + template + '.js'], callback, function (err) { console.error('Unable to load template: ' + template); throw err; }); diff --git a/public/src/client/topic/postTools.js b/public/src/client/topic/postTools.js index 00961bc883..577b116580 100644 --- a/public/src/client/topic/postTools.js +++ b/public/src/client/topic/postTools.js @@ -11,8 +11,7 @@ define('forum/topic/postTools', [ 'bootbox', 'alerts', 'hooks', - 'slugify', -], function (share, navigator, components, translator, votes, api, bootbox, alerts, hooks, slugify) { +], function (share, navigator, components, translator, votes, api, bootbox, alerts, hooks) { const PostTools = {}; let staleReplyAnyway = false; @@ -256,8 +255,8 @@ define('forum/topic/postTools', [ function onReplyClicked(button, tid) { const selectedNode = getSelectedNode(); - showStaleWarning(function () { - let username = getUserSlug(button); + showStaleWarning(async function () { + let username = await getUserSlug(button); if (getData(button, 'data-uid') === '0' || !getData(button, 'data-userslug')) { username = ''; } @@ -289,8 +288,8 @@ define('forum/topic/postTools', [ function onQuoteClicked(button, tid) { const selectedNode = getSelectedNode(); - showStaleWarning(function () { - const username = getUserSlug(button); + showStaleWarning(async function () { + const username = await getUserSlug(button); const toPid = getData(button, 'data-pid'); function quote(text) { @@ -316,7 +315,7 @@ define('forum/topic/postTools', [ }); } - function getSelectedNode() { + async function getSelectedNode() { let selectedText = ''; let selectedPid; let username = ''; @@ -343,7 +342,7 @@ define('forum/topic/postTools', [ selectedText = range.toString(); const postEl = $(content).parents('[component="post"]'); selectedPid = postEl.attr('data-pid'); - username = getUserSlug($(content)); + username = await getUserSlug($(content)); range.detach(); } return { text: selectedText, pid: selectedPid, username: username }; @@ -367,28 +366,33 @@ define('forum/topic/postTools', [ } function getUserSlug(button) { - let slug = ''; - const post = button.parents('[data-pid]'); - - if (button.attr('component') === 'topic/reply') { - return slug; - } - - if (post.length) { - slug = slugify(post.attr('data-username'), true); - if (!slug) { - if (post.attr('data-uid') !== '0') { - slug = '[[global:former_user]]'; - } else { - slug = '[[global:guest]]'; - } + return new Promise((resolve) => { + let slug = ''; + if (button.attr('component') === 'topic/reply') { + resolve(slug); + return; + } + const post = button.parents('[data-pid]'); + if (post.length) { + require(['slugify'], function (slugify) { + slug = slugify(post.attr('data-username'), true); + if (!slug) { + if (post.attr('data-uid') !== '0') { + slug = '[[global:former_user]]'; + } else { + slug = '[[global:guest]]'; + } + } + if (slug && slug !== '[[global:former_user]]' && slug !== '[[global:guest]]') { + slug = '@' + slug; + } + resolve(slug); + }); + return; } - } - if (post.length && post.attr('data-uid') !== '0') { - slug = '@' + slug; - } - return slug; + resolve(slug); + }); } function togglePostDelete(button) { diff --git a/public/src/modules/translator.js b/public/src/modules/translator.js index 00572536d5..eb40cf658f 100644 --- a/public/src/modules/translator.js +++ b/public/src/modules/translator.js @@ -3,7 +3,7 @@ (function (factory) { function loadClient(language, namespace) { return new Promise(function (resolve, reject) { - jQuery.getJSON([config.assetBaseUrl, 'language', language, namespace].join('/') + '.json?' + config['cache-buster'], function (data) { + jQuery.getJSON([config.asset_base_url, 'language', language, namespace].join('/') + '.json?' + config['cache-buster'], function (data) { const payload = { language: language, namespace: namespace, diff --git a/src/cli/package-install.js b/src/cli/package-install.js index f2e4e94913..d5e99b16d4 100644 --- a/src/cli/package-install.js +++ b/src/cli/package-install.js @@ -3,7 +3,6 @@ const path = require('path'); const fs = require('fs'); const cproc = require('child_process'); -const _ = require('lodash'); const { paths, pluginNamePattern } = require('../constants'); @@ -18,6 +17,23 @@ function sortDependencies(dependencies) { }, {}); } +function merge(to, from) { + // Poor man's version of _.merge() + if (Object.values(from).every(val => typeof val !== 'object')) { + return Object.assign(to, from); + } + + Object.keys(from).forEach((key) => { + if (Object.getPrototypeOf(from[key]) === Object.prototype) { + to[key] = merge(to[key], from[key]); + } else { + to[key] = from[key]; + } + }); + + return to; +} + pkgInstall.updatePackageFile = () => { let oldPackageContents = {}; @@ -43,8 +59,8 @@ pkgInstall.updatePackageFile = () => { // Sort dependencies alphabetically dependencies = sortDependencies({ ...dependencies, ...defaultPackageContents.dependencies }); - const packageContents = { ..._.merge(oldPackageContents, defaultPackageContents), dependencies, devDependencies }; - fs.writeFileSync(paths.currentPackage, JSON.stringify(packageContents, null, 2)); + const packageContents = { ...merge(oldPackageContents, defaultPackageContents), dependencies, devDependencies }; + fs.writeFileSync(paths.currentPackage, JSON.stringify(packageContents, null, 4)); }; pkgInstall.supportedPackageManager = [ @@ -169,5 +185,5 @@ pkgInstall.preserveExtraneousPlugins = () => { // Add those packages to package.json packageContents.dependencies = sortDependencies({ ...packageContents.dependencies, ...extraneous }); - fs.writeFileSync(paths.currentPackage, JSON.stringify(packageContents, null, 2)); + fs.writeFileSync(paths.currentPackage, JSON.stringify(packageContents, null, 4)); }; diff --git a/src/controllers/accounts/posts.js b/src/controllers/accounts/posts.js index dabfd8ece8..a43a70ccd4 100644 --- a/src/controllers/accounts/posts.js +++ b/src/controllers/accounts/posts.js @@ -49,7 +49,7 @@ const templateToData = { }, 'account/best': { type: 'posts', - noItemsFoundKey: '[[user:has_no_voted_posts]]', + noItemsFoundKey: '[[user:has_no_best_posts]]', crumb: '[[global:best]]', getSets: async function (callerUid, userData) { const cids = await categories.getCidsByPrivilege('categories:cid', callerUid, 'topics:read'); @@ -60,10 +60,14 @@ const templateToData = { const postObjs = await posts.getPostSummaryByPids(pids, req.uid, { stripTags: false }); return { posts: postObjs, nextStart: stop + 1 }; }, + getItemCount: async (sets) => { + const counts = await Promise.all(sets.map(set => db.sortedSetCount(set, 1, '+inf'))); + return counts.reduce((acc, val) => acc + val, 0); + }, }, 'account/controversial': { type: 'posts', - noItemsFoundKey: '[[user:has_no_voted_posts]]', + noItemsFoundKey: '[[user:has_no_controversial_posts]]', crumb: '[[global:controversial]]', getSets: async function (callerUid, userData) { const cids = await categories.getCidsByPrivilege('categories:cid', callerUid, 'topics:read'); @@ -74,6 +78,10 @@ const templateToData = { const postObjs = await posts.getPostSummaryByPids(pids, req.uid, { stripTags: false }); return { posts: postObjs, nextStart: stop + 1 }; }, + getItemCount: async (sets) => { + const counts = await Promise.all(sets.map(set => db.sortedSetCount(set, '-inf', -1))); + return counts.reduce((acc, val) => acc + val, 0); + }, }, 'account/watched': { type: 'topics', @@ -194,7 +202,7 @@ async function getPostsFromUserSet(template, req, res, next) { }); } else { result = await utils.promiseParallel({ - itemCount: settings.usePagination ? db.sortedSetsCardSum(sets) : 0, + itemCount: getItemCount(sets, data, settings), itemData: getItemData(sets, data, req, start, stop), }); } @@ -231,3 +239,13 @@ async function getItemData(sets, data, req, start, stop) { const method = data.type === 'topics' ? topics.getTopicsFromSet : posts.getPostSummariesFromSet; return await method(sets, req.uid, start, stop); } + +async function getItemCount(sets, data, settings) { + if (!settings.usePagination) { + return 0; + } + if (data.getItemCount) { + return await data.getItemCount(sets); + } + return await db.sortedSetsCardSum(sets); +} diff --git a/src/controllers/api.js b/src/controllers/api.js index 1194a75399..7474f6e7a0 100644 --- a/src/controllers/api.js +++ b/src/controllers/api.js @@ -14,6 +14,7 @@ const apiController = module.exports; const relative_path = nconf.get('relative_path'); const upload_url = nconf.get('upload_url'); +const asset_base_url = nconf.get('asset_base_url'); const socketioTransports = nconf.get('socket.io:transports') || ['polling', 'websocket']; const socketioOrigins = nconf.get('socket.io:origins'); const websocketAddress = nconf.get('socket.io:address') || ''; @@ -22,7 +23,8 @@ apiController.loadConfig = async function (req) { const config = { relative_path, upload_url, - assetBaseUrl: `${relative_path}/assets`, + asset_base_url, + assetBaseUrl: asset_base_url, // deprecate in 1.20.x siteTitle: validator.escape(String(meta.config.title || meta.config.browserTitle || 'NodeBB')), browserTitle: validator.escape(String(meta.config.browserTitle || meta.config.title || 'NodeBB')), titleLayout: (meta.config.titleLayout || '{pageTitle} | {browserTitle}').replace(/{/g, '{').replace(/}/g, '}'), diff --git a/src/controllers/uploads.js b/src/controllers/uploads.js index 01adb2f8b8..be03ed6ffc 100644 --- a/src/controllers/uploads.js +++ b/src/controllers/uploads.js @@ -4,7 +4,7 @@ const path = require('path'); const nconf = require('nconf'); const validator = require('validator'); -const db = require('../database'); +const user = require('../user'); const meta = require('../meta'); const file = require('../file'); const plugins = require('../plugins'); @@ -190,8 +190,8 @@ async function saveFileToLocal(uid, folder, uploadedFile) { path: upload.path, name: uploadedFile.name, }; - const fileKey = upload.url.replace(nconf.get('upload_url'), ''); - await db.sortedSetAdd(`uid:${uid}:uploads`, Date.now(), fileKey); + + await user.associateUpload(uid, upload.url.replace(`${nconf.get('upload_url')}/`, '')); const data = await plugins.hooks.fire('filter:uploadStored', { uid: uid, uploadedFile: uploadedFile, storedFile: storedFile }); return data.storedFile; } diff --git a/src/install.js b/src/install.js index 3067a05ab5..f9f7912d9f 100644 --- a/src/install.js +++ b/src/install.js @@ -46,22 +46,54 @@ questions.optional = [ }, ]; -function checkSetupFlag() { - let setupVal = install.values; +function checkSetupFlagEnv() { + let setupVal = install.values || {}; + + const envConfMap = { + NODEBB_URL: 'url', + NODEBB_PORT: 'port', + NODEBB_ADMIN_USERNAME: 'admin:username', + NODEBB_ADMIN_PASSWORD: 'admin:password', + NODEBB_ADMIN_EMAIL: 'admin:email', + NODEBB_DB: 'database', + NODEBB_DB_HOST: 'host', + NODEBB_DB_PORT: 'port', + NODEBB_DB_USER: 'username', + NODEBB_DB_PASSWORD: 'password', + NODEBB_DB_NAME: 'database', + NODEBB_DB_SSL: 'ssl', + }; + + // Set setup values from env vars (if set) + winston.info('[install/checkSetupFlagEnv] checking env vars for setup info...'); + + Object.entries(process.env).forEach(([evName, evValue]) => { // get setup values from env + if (evName.startsWith('NODEBB_DB_')) { + setupVal[`${process.env.NODEBB_DB}:${envConfMap[evName]}`] = evValue; + } else if (evName.startsWith('NODEBB_')) { + setupVal[envConfMap[evName]] = evValue; + } + }); + + setupVal['admin:password:confirm'] = setupVal['admin:password']; + // try to get setup values from json, if successful this overwrites all values set by env + // TODO: better behaviour would be to support overrides per value, i.e. in order of priority (generic pattern): + // flag, env, config file, default try { if (nconf.get('setup')) { - setupVal = JSON.parse(nconf.get('setup')); + const setupJSON = JSON.parse(nconf.get('setup')); + setupVal = { ...setupVal, ...setupJSON }; } } catch (err) { - winston.error('Invalid json in nconf.get(\'setup\'), ignoring setup values'); + winston.error('[install/checkSetupFlagEnv] invalid json in nconf.get(\'setup\'), ignoring setup values from json'); } if (setupVal && typeof setupVal === 'object') { if (setupVal['admin:username'] && setupVal['admin:password'] && setupVal['admin:password:confirm'] && setupVal['admin:email']) { install.values = setupVal; } else { - winston.error('Required values are missing for automated setup:'); + winston.error('[install/checkSetupFlagEnv] required values are missing for automated setup:'); if (!setupVal['admin:username']) { winston.error(' admin:username'); } @@ -95,7 +127,7 @@ function checkCIFlag() { if (ciVals.hasOwnProperty('host') && ciVals.hasOwnProperty('port') && ciVals.hasOwnProperty('database')) { install.ciVals = ciVals; } else { - winston.error('Required values are missing for automated CI integration:'); + winston.error('[install/checkCIFlag] required values are missing for automated CI integration:'); if (!ciVals.hasOwnProperty('host')) { winston.error(' host'); } @@ -521,7 +553,7 @@ async function checkUpgrade() { install.setup = async function () { try { - checkSetupFlag(); + checkSetupFlagEnv(); checkCIFlag(); await setupConfig(); await setupDefaultConfigs(); diff --git a/src/posts/delete.js b/src/posts/delete.js index 9cb26b2860..aaf2618186 100644 --- a/src/posts/delete.js +++ b/src/posts/delete.js @@ -60,6 +60,7 @@ module.exports = function (Posts) { deletePostFromUsersVotes(pid), deletePostFromReplies(postData), deletePostFromGroups(postData), + deletePostDiffs(pid), db.sortedSetsRemove(['posts:pid', 'posts:votes', 'posts:flagged'], pid), Posts.uploads.dissociateAll(pid), ]); @@ -144,4 +145,12 @@ module.exports = function (Posts) { const keys = groupNames[0].map(groupName => `group:${groupName}:member:pids`); await db.sortedSetsRemove(keys, postData.pid); } + + async function deletePostDiffs(pid) { + const timestamps = await Posts.diffs.list(pid); + await db.deleteAll([ + `post:${pid}:diffs`, + ...timestamps.map(t => `diff:${pid}.${t}`), + ]); + } }; diff --git a/src/posts/uploads.js b/src/posts/uploads.js index cfab10671b..b7916f8ad1 100644 --- a/src/posts/uploads.js +++ b/src/posts/uploads.js @@ -1,6 +1,5 @@ 'use strict'; -const async = require('async'); const nconf = require('nconf'); const crypto = require('crypto'); const path = require('path'); @@ -10,15 +9,23 @@ const validator = require('validator'); const db = require('../database'); const image = require('../image'); +const user = require('../user'); const topics = require('../topics'); const file = require('../file'); +const meta = require('../meta'); module.exports = function (Posts) { Posts.uploads = {}; const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); - const pathPrefix = path.join(nconf.get('upload_path'), 'files'); - const searchRegex = /\/assets\/uploads\/files\/([^\s")]+\.?[\w]*)/g; + const pathPrefix = path.join(nconf.get('upload_path')); + const searchRegex = /\/assets\/uploads\/(files\/[^\s")]+\.?[\w]*)/g; + + const _getFullPath = relativePath => path.join(pathPrefix, relativePath); + const _filterValidPaths = async filePaths => (await Promise.all(filePaths.map(async (filePath) => { + const fullPath = _getFullPath(filePath); + return fullPath.startsWith(pathPrefix) && await file.exists(fullPath) ? filePath : false; + }))).filter(Boolean); Posts.uploads.sync = async function (pid) { // Scans a post's content and updates sorted set of uploads @@ -41,7 +48,7 @@ module.exports = function (Posts) { if (isMainPost) { const tid = await Posts.getPostField(pid, 'tid'); let thumbs = await topics.thumbs.get(tid); - const replacePath = path.posix.join(nconf.get('relative_path'), nconf.get('upload_url'), 'files/'); + const replacePath = path.posix.join(`${nconf.get('relative_path')}${nconf.get('upload_url')}/`); thumbs = thumbs.map(thumb => thumb.url.replace(replacePath, '')).filter(path => !validator.isURL(path, { require_protocol: true, })); @@ -92,8 +99,7 @@ module.exports = function (Posts) { if (!filePaths.length) { return; } - // Only process files that exist - filePaths = await async.filter(filePaths, async filePath => await file.exists(path.join(pathPrefix, filePath))); + filePaths = await _filterValidPaths(filePaths); // Only process files that exist and are within uploads directory const now = Date.now(); const scores = filePaths.map(() => now); @@ -113,15 +119,40 @@ module.exports = function (Posts) { } const bulkRemove = filePaths.map(path => [`upload:${md5(path)}:pids`, pid]); - await Promise.all([ + const promises = [ db.sortedSetRemove(`post:${pid}:uploads`, filePaths), db.sortedSetRemoveBulk(bulkRemove), - ]); + ]; + + await Promise.all(promises); + + if (!meta.config.preserveOrphanedUploads) { + const deletePaths = (await Promise.all( + filePaths.map(async filePath => (await Posts.uploads.isOrphan(filePath) ? filePath : false)) + )).filter(Boolean); + + const uploaderUids = (await db.getObjectsFields(deletePaths.map(path => `upload:${md5(path)}`, ['uid']))).map(o => (o ? o.uid || null : null)); + await Promise.all(uploaderUids.map((uid, idx) => ( + uid && isFinite(uid) ? user.deleteUpload(uid, uid, deletePaths[idx]) : null + )).filter(Boolean)); + await Posts.uploads.deleteFromDisk(deletePaths); + } }; Posts.uploads.dissociateAll = async (pid) => { const current = await Posts.uploads.list(pid); - await Promise.all(current.map(async path => await Posts.uploads.dissociate(pid, path))); + await Posts.uploads.dissociate(pid, current); + }; + + Posts.uploads.deleteFromDisk = async (filePaths) => { + if (typeof filePaths === 'string') { + filePaths = [filePaths]; + } else if (!Array.isArray(filePaths)) { + throw new Error(`[[error:wrong-parameter-type, filePaths, ${typeof filePaths}, array]]`); + } + + filePaths = (await _filterValidPaths(filePaths)).map(_getFullPath); + await Promise.all(filePaths.map(file.delete)); }; Posts.uploads.saveSize = async (filePaths) => { @@ -131,8 +162,8 @@ module.exports = function (Posts) { }); await Promise.all(filePaths.map(async (fileName) => { try { - const size = await image.size(path.join(pathPrefix, fileName)); - winston.verbose(`[posts/uploads/${fileName}] Saving size`); + const size = await image.size(_getFullPath(fileName)); + winston.verbose(`[posts/uploads/${fileName}] Saving size (${size.width}px x ${size.height}px)`); await db.setObject(`upload:${md5(fileName)}`, { width: size.width, height: size.height, diff --git a/src/prestart.js b/src/prestart.js index 55dcb72235..b93bf05838 100644 --- a/src/prestart.js +++ b/src/prestart.js @@ -95,6 +95,7 @@ function loadConfig(configFile) { nconf.set('secure', urlObject.protocol === 'https:'); nconf.set('use_port', !!urlObject.port); nconf.set('relative_path', relativePath); + nconf.set('asset_base_url', `${relativePath}/assets`); nconf.set('port', nconf.get('PORT') || nconf.get('port') || urlObject.port || (nconf.get('PORT_ENV_VAR') ? nconf.get(nconf.get('PORT_ENV_VAR')) : false) || 4567); // cookies don't provide isolation by port: http://stackoverflow.com/a/16328399/122353 diff --git a/src/topics/thumbs.js b/src/topics/thumbs.js index 3cb72afe12..f2bacf74d3 100644 --- a/src/topics/thumbs.js +++ b/src/topics/thumbs.js @@ -91,7 +91,7 @@ Thumbs.associate = async function ({ id, path, score }) { // Associate thumbnails with the main pid (only on local upload) if (!isDraft && isLocal) { const mainPid = (await topics.getMainPids([id]))[0]; - await posts.uploads.associate(mainPid, path.replace('/files/', '')); + await posts.uploads.associate(mainPid, path.slice(1)); } }; @@ -136,10 +136,11 @@ Thumbs.delete = async function (id, relativePaths) { } }); - await Promise.all([ - db.sortedSetRemove(set, toRemove), - Promise.all(toDelete.map(async absolutePath => file.delete(absolutePath))), - ]); + await db.sortedSetRemove(set, toRemove); + + if (isDraft && toDelete.length) { // drafts only; post upload dissociation handles disk deletion for topics + await Promise.all(toDelete.map(async absolutePath => file.delete(absolutePath))); + } if (toRemove.length && !isDraft) { const topics = require('.'); @@ -147,7 +148,7 @@ Thumbs.delete = async function (id, relativePaths) { await Promise.all([ db.incrObjectFieldBy(`topic:${id}`, 'numThumbs', -toRemove.length), - Promise.all(toRemove.map(async relativePath => posts.uploads.dissociate(mainPid, relativePath.replace('/files/', '')))), + Promise.all(toRemove.map(async relativePath => posts.uploads.dissociate(mainPid, relativePath.slice(1)))), ]); } }; diff --git a/src/upgrades/1.12.1/post_upload_sizes.js b/src/upgrades/1.12.1/post_upload_sizes.js index 37df4e70c8..8c545b1657 100644 --- a/src/upgrades/1.12.1/post_upload_sizes.js +++ b/src/upgrades/1.12.1/post_upload_sizes.js @@ -10,11 +10,11 @@ module.exports = { method: async function () { const { progress } = this; - await batch.processSortedSet('posts:pid', async (postData) => { - const keys = postData.map(p => `post:${p.pid}:uploads`); + await batch.processSortedSet('posts:pid', async (pids) => { + const keys = pids.map(p => `post:${p}:uploads`); const uploads = await db.getSortedSetRange(keys, 0, -1); await posts.uploads.saveSize(uploads); - progress.incr(postData.length); + progress.incr(pids.length); }, { batch: 100, progress: progress, diff --git a/src/upgrades/1.19.3/fix_user_uploads_zset.js b/src/upgrades/1.19.3/fix_user_uploads_zset.js new file mode 100644 index 0000000000..e5989ba75d --- /dev/null +++ b/src/upgrades/1.19.3/fix_user_uploads_zset.js @@ -0,0 +1,46 @@ +/* eslint-disable no-await-in-loop */ + +'use strict'; + +const crypto = require('crypto'); + +const db = require('../../database'); +const batch = require('../../batch'); + +const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); + +module.exports = { + name: 'Fix paths in user uploads sorted sets', + timestamp: Date.UTC(2022, 1, 10), + method: async function () { + const { progress } = this; + + await batch.processSortedSet('users:joindate', async (uids) => { + const keys = uids.map(uid => `uid:${uid}:uploads`); + progress.incr(uids.length); + + for (let idx = 0; idx < uids.length; idx++) { + const key = keys[idx]; + // Rename the paths within + let uploads = await db.getSortedSetRangeWithScores(key, 0, -1); + + // Don't process those that have already the right format + uploads = uploads.filter(upload => upload.value.startsWith('/files/')); + + await db.sortedSetRemove(key, uploads.map(upload => upload.value)); + await db.sortedSetAdd( + key, + uploads.map(upload => upload.score), + uploads.map(upload => upload.value.slice(1)) + ); + + // Add uid to the upload's hash object + uploads = await db.getSortedSetMembers(key); + await db.setObjectBulk(uploads.map(relativePath => [`upload:${md5(relativePath)}`, { uid: uids[idx] }])); + } + }, { + batch: 100, + progress: progress, + }); + }, +}; diff --git a/src/upgrades/1.19.3/rename_post_upload_hashes.js b/src/upgrades/1.19.3/rename_post_upload_hashes.js new file mode 100644 index 0000000000..5664243c3f --- /dev/null +++ b/src/upgrades/1.19.3/rename_post_upload_hashes.js @@ -0,0 +1,63 @@ +/* eslint-disable no-await-in-loop */ + +'use strict'; + +const crypto = require('crypto'); + +const db = require('../../database'); +const batch = require('../../batch'); + +const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); + +module.exports = { + name: 'Rename object and sorted sets used in post uploads', + timestamp: Date.UTC(2022, 1, 10), + method: async function () { + const { progress } = this; + + await batch.processSortedSet('posts:pid', async (pids) => { + let keys = pids.map(pid => `post:${pid}:uploads`); + const exists = await db.exists(keys); + keys = keys.filter((key, idx) => exists[idx]); + + progress.incr(pids.length); + + for (const key of keys) { + // Rename the paths within + let uploads = await db.getSortedSetRangeWithScores(key, 0, -1); + + // Don't process those that have already the right format + uploads = uploads.filter(upload => upload && upload.value && !upload.value.startsWith('files/')); + + // Rename the zset members + await db.sortedSetRemove(key, uploads.map(upload => upload.value)); + await db.sortedSetAdd( + key, + uploads.map(upload => upload.score), + uploads.map(upload => `files/${upload.value}`) + ); + + // Rename the object and pids zsets + const hashes = uploads.map(upload => md5(upload.value)); + const newHashes = uploads.map(upload => md5(`files/${upload.value}`)); + + // cant use db.rename since `fix_user_uploads_zset.js` upgrade script already creates + // `upload:md5(upload.value) hash, trying to rename to existing key results in dupe error + const oldData = await db.getObjects(hashes.map(hash => `upload:${hash}`)); + const bulkSet = []; + oldData.forEach((data, idx) => { + if (data) { + bulkSet.push([`upload:${newHashes[idx]}`, data]); + } + }); + await db.setObjectBulk(bulkSet); + await db.deleteAll(hashes.map(hash => `upload:${hash}`)); + + await Promise.all(hashes.map((hash, idx) => db.rename(`upload:${hash}:pids`, `upload:${newHashes[idx]}:pids`))); + } + }, { + batch: 100, + progress: progress, + }); + }, +}; diff --git a/src/user/delete.js b/src/user/delete.js index 1643d4643a..917d8a97c9 100644 --- a/src/user/delete.js +++ b/src/user/delete.js @@ -15,14 +15,12 @@ const groups = require('../groups'); const messaging = require('../messaging'); const plugins = require('../plugins'); const batch = require('../batch'); -const file = require('../file'); module.exports = function (User) { const deletesInProgress = {}; User.delete = async (callerUid, uid) => { await User.deleteContent(callerUid, uid); - await removeFromSortedSets(uid); return await User.deleteAccount(uid); }; @@ -36,7 +34,7 @@ module.exports = function (User) { deletesInProgress[uid] = 'user.delete'; await deletePosts(callerUid, uid); await deleteTopics(callerUid, uid); - await deleteUploads(uid); + await deleteUploads(callerUid, uid); await deleteQueued(uid); delete deletesInProgress[uid]; }; @@ -57,13 +55,9 @@ module.exports = function (User) { }, { alwaysStartAt: 0 }); } - async function deleteUploads(uid) { - await batch.processSortedSet(`uid:${uid}:uploads`, async (uploadNames) => { - await async.each(uploadNames, async (uploadName) => { - await file.delete(path.join(nconf.get('upload_path'), uploadName)); - }); - await db.sortedSetRemove(`uid:${uid}:uploads`, uploadNames); - }, { alwaysStartAt: 0 }); + async function deleteUploads(callerUid, uid) { + const uploads = await db.getSortedSetMembers(`uid:${uid}:uploads`); + await User.deleteUpload(callerUid, uid, uploads); } async function deleteQueued(uid) { @@ -114,8 +108,11 @@ module.exports = function (User) { `uid:${uid}:notifications:read`, `uid:${uid}:notifications:unread`, `uid:${uid}:bookmarks`, + `uid:${uid}:tids_read`, + `uid:${uid}:tids_unread`, `uid:${uid}:followed_tids`, `uid:${uid}:ignored_tids`, + `uid:${uid}:blocked_uids`, `user:${uid}:settings`, `user:${uid}:usernames`, `user:${uid}:emails`, diff --git a/src/user/profile.js b/src/user/profile.js index 6f77806d4f..3c93cb8bd0 100644 --- a/src/user/profile.js +++ b/src/user/profile.js @@ -4,7 +4,6 @@ const _ = require('lodash'); const validator = require('validator'); const winston = require('winston'); -const punycode = require('punycode'); const utils = require('../utils'); const slugify = require('../slugify'); @@ -46,28 +45,14 @@ module.exports = function (User) { data[field] = data[field].trim(); - switch (field) { - case 'email': { - return await updateEmail(updateUid, data.email); - } - - case 'username': { - return await updateUsername(updateUid, data.username); - } - - case 'fullname': { - return await updateFullname(updateUid, data.fullname); - } - - case 'website': { - updateData[field] = punycode.toASCII(data[field]); - break; - } - - default: { - updateData[field] = data[field]; - } + if (field === 'email') { + return await updateEmail(updateUid, data.email); + } else if (field === 'username') { + return await updateUsername(updateUid, data.username); + } else if (field === 'fullname') { + return await updateFullname(updateUid, data.fullname); } + updateData[field] = data[field]; })); if (Object.keys(updateData).length) { diff --git a/src/user/uploads.js b/src/user/uploads.js index 066730249e..14c7a67b34 100644 --- a/src/user/uploads.js +++ b/src/user/uploads.js @@ -3,37 +3,83 @@ const path = require('path'); const nconf = require('nconf'); const winston = require('winston'); +const crypto = require('crypto'); const db = require('../database'); +const posts = require('../posts'); const file = require('../file'); const batch = require('../batch'); +const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); +const _getFullPath = relativePath => path.resolve(nconf.get('upload_path'), relativePath); +const _validatePath = async (relativePaths) => { + if (typeof relativePaths === 'string') { + relativePaths = [relativePaths]; + } else if (!Array.isArray(relativePaths)) { + throw new Error(`[[error:wrong-parameter-type, relativePaths, ${typeof relativePaths}, array]]`); + } + + const fullPaths = relativePaths.map(path => _getFullPath(path)); + const exists = await Promise.all(fullPaths.map(async fullPath => file.exists(fullPath))); + + if (!fullPaths.every(fullPath => fullPath.startsWith(nconf.get('upload_path'))) || !exists.every(Boolean)) { + throw new Error('[[error:invalid-path]]'); + } +}; + module.exports = function (User) { - User.deleteUpload = async function (callerUid, uid, uploadName) { + User.associateUpload = async (uid, relativePath) => { + await _validatePath(relativePath); + await Promise.all([ + db.sortedSetAdd(`uid:${uid}:uploads`, Date.now(), relativePath), + db.setObjectField(`upload:${md5(relativePath)}`, 'uid', uid), + ]); + }; + + User.deleteUpload = async function (callerUid, uid, uploadNames) { + if (typeof uploadNames === 'string') { + uploadNames = [uploadNames]; + } else if (!Array.isArray(uploadNames)) { + throw new Error(`[[error:wrong-parameter-type, uploadNames, ${typeof uploadNames}, array]]`); + } + + await _validatePath(uploadNames); + const [isUsersUpload, isAdminOrGlobalMod] = await Promise.all([ - db.isSortedSetMember(`uid:${callerUid}:uploads`, uploadName), + db.isSortedSetMembers(`uid:${callerUid}:uploads`, uploadNames), User.isAdminOrGlobalMod(callerUid), ]); - if (!isAdminOrGlobalMod && !isUsersUpload) { + if (!isAdminOrGlobalMod && !isUsersUpload.every(Boolean)) { throw new Error('[[error:no-privileges]]'); } - const finalPath = path.join(nconf.get('upload_path'), uploadName); - if (!finalPath.startsWith(nconf.get('upload_path'))) { - throw new Error('[[error:invalid-path]]'); - } - winston.verbose(`[user/deleteUpload] Deleting ${uploadName}`); - await Promise.all([ - file.delete(finalPath), - file.delete(file.appendToFileName(finalPath, '-resized')), - ]); - await db.sortedSetRemove(`uid:${uid}:uploads`, uploadName); + await batch.processArray(uploadNames, async (uploadNames) => { + const fullPaths = uploadNames.map(path => _getFullPath(path)); + + await Promise.all(fullPaths.map(async (fullPath, idx) => { + winston.verbose(`[user/deleteUpload] Deleting ${uploadNames[idx]}`); + await Promise.all([ + file.delete(fullPath), + file.delete(file.appendToFileName(fullPath, '-resized')), + ]); + await Promise.all([ + db.sortedSetRemove(`uid:${uid}:uploads`, uploadNames[idx]), + db.delete(`upload:${md5(uploadNames[idx])}`), + ]); + })); + + // Dissociate the upload from pids, if any + const pids = await db.getSortedSetsMembers(uploadNames.map(relativePath => `upload:${md5(relativePath)}:pids`)); + await Promise.all(pids.map(async (pids, idx) => Promise.all( + pids.map(async pid => posts.uploads.dissociate(pid, uploadNames[idx])) + ))); + }, { batch: 50 }); }; User.collateUploads = async function (uid, archive) { await batch.processSortedSet(`uid:${uid}:uploads`, (files, next) => { files.forEach((file) => { - archive.file(path.join(nconf.get('upload_path'), file), { + archive.file(_getFullPath(file), { name: path.basename(file), }); }); diff --git a/src/views/admin/settings/uploads.tpl b/src/views/admin/settings/uploads.tpl index 36edc6ff8a..b2b1bb8c47 100644 --- a/src/views/admin/settings/uploads.tpl +++ b/src/views/admin/settings/uploads.tpl @@ -8,15 +8,22 @@
+
+ +
+
diff --git a/test/mocks/databasemock.js b/test/mocks/databasemock.js index 911398b708..414cb7cfdd 100644 --- a/test/mocks/databasemock.js +++ b/test/mocks/databasemock.js @@ -38,6 +38,7 @@ nconf.defaults({ const urlObject = url.parse(nconf.get('url')); const relativePath = urlObject.pathname !== '/' ? urlObject.pathname : ''; nconf.set('relative_path', relativePath); +nconf.set('asset_base_url', `${relativePath}/assets`); nconf.set('upload_path', path.join(nconf.get('base_dir'), nconf.get('upload_path'))); nconf.set('upload_url', '/assets/uploads'); nconf.set('url_parsed', urlObject); diff --git a/test/posts.js b/test/posts.js index 8c2a7d3a5c..fc9b89f44b 100644 --- a/test/posts.js +++ b/test/posts.js @@ -5,8 +5,6 @@ const assert = require('assert'); const async = require('async'); const request = require('request'); const nconf = require('nconf'); -const crypto = require('crypto'); -const fs = require('fs'); const path = require('path'); const util = require('util'); @@ -20,10 +18,10 @@ const privileges = require('../src/privileges'); const user = require('../src/user'); const groups = require('../src/groups'); const socketPosts = require('../src/socket.io/posts'); -const socketTopics = require('../src/socket.io/topics'); const apiPosts = require('../src/api/posts'); const apiTopics = require('../src/api/topics'); const meta = require('../src/meta'); +const file = require('../src/file'); const helpers = require('./helpers'); describe('Post\'s', () => { @@ -1122,258 +1120,6 @@ describe('Post\'s', () => { }); }); - describe('upload methods', () => { - let pid; - let purgePid; - - before(async () => { - // Create stub files for testing - ['abracadabra.png', 'shazam.jpg', 'whoa.gif', 'amazeballs.jpg', 'wut.txt', 'test.bmp'] - .forEach(filename => fs.closeSync(fs.openSync(path.join(nconf.get('upload_path'), 'files', filename), 'w'))); - - const topicPostData = await topics.post({ - uid: 1, - cid: 1, - title: 'topic with some images', - content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png) and another [alt text](/assets/uploads/files/shazam.jpg)', - }); - pid = topicPostData.postData.pid; - - const purgePostData = await topics.post({ - uid: 1, - cid: 1, - title: 'topic with some images, to be purged', - content: 'here is an image [alt text](/assets/uploads/files/whoa.gif) and another [alt text](/assets/uploads/files/amazeballs.jpg)', - }); - purgePid = purgePostData.postData.pid; - }); - - describe('.sync()', () => { - it('should properly add new images to the post\'s zset', (done) => { - posts.uploads.sync(pid, (err) => { - assert.ifError(err); - - db.sortedSetCard(`post:${pid}:uploads`, (err, length) => { - assert.ifError(err); - assert.strictEqual(length, 2); - done(); - }); - }); - }); - - it('should remove an image if it is edited out of the post', (done) => { - async.series([ - function (next) { - posts.edit({ - pid: pid, - uid: 1, - content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png)... AND NO MORE!', - }, next); - }, - async.apply(posts.uploads.sync, pid), - ], (err) => { - assert.ifError(err); - db.sortedSetCard(`post:${pid}:uploads`, (err, length) => { - assert.ifError(err); - assert.strictEqual(1, length); - done(); - }); - }); - }); - }); - - describe('.list()', () => { - it('should display the uploaded files for a specific post', (done) => { - posts.uploads.list(pid, (err, uploads) => { - assert.ifError(err); - assert.equal(true, Array.isArray(uploads)); - assert.strictEqual(1, uploads.length); - assert.equal('string', typeof uploads[0]); - done(); - }); - }); - }); - - describe('.isOrphan()', () => { - it('should return false if upload is not an orphan', (done) => { - posts.uploads.isOrphan('abracadabra.png', (err, isOrphan) => { - assert.ifError(err); - assert.equal(false, isOrphan); - done(); - }); - }); - - it('should return true if upload is an orphan', (done) => { - posts.uploads.isOrphan('shazam.jpg', (err, isOrphan) => { - assert.ifError(err); - assert.equal(true, isOrphan); - done(); - }); - }); - }); - - describe('.associate()', () => { - it('should add an image to the post\'s maintained list of uploads', (done) => { - async.waterfall([ - async.apply(posts.uploads.associate, pid, 'whoa.gif'), - async.apply(posts.uploads.list, pid), - ], (err, uploads) => { - assert.ifError(err); - assert.strictEqual(2, uploads.length); - assert.strictEqual(true, uploads.includes('whoa.gif')); - done(); - }); - }); - - it('should allow arrays to be passed in', (done) => { - async.waterfall([ - async.apply(posts.uploads.associate, pid, ['amazeballs.jpg', 'wut.txt']), - async.apply(posts.uploads.list, pid), - ], (err, uploads) => { - assert.ifError(err); - assert.strictEqual(4, uploads.length); - assert.strictEqual(true, uploads.includes('amazeballs.jpg')); - assert.strictEqual(true, uploads.includes('wut.txt')); - done(); - }); - }); - - it('should save a reverse association of md5sum to pid', (done) => { - const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); - - async.waterfall([ - async.apply(posts.uploads.associate, pid, ['test.bmp']), - function (next) { - db.getSortedSetRange(`upload:${md5('test.bmp')}:pids`, 0, -1, next); - }, - ], (err, pids) => { - assert.ifError(err); - assert.strictEqual(true, Array.isArray(pids)); - assert.strictEqual(true, pids.length > 0); - assert.equal(pid, pids[0]); - done(); - }); - }); - - it('should not associate a file that does not exist on the local disk', (done) => { - async.waterfall([ - async.apply(posts.uploads.associate, pid, ['nonexistant.xls']), - async.apply(posts.uploads.list, pid), - ], (err, uploads) => { - assert.ifError(err); - assert.strictEqual(uploads.length, 5); - assert.strictEqual(false, uploads.includes('nonexistant.xls')); - done(); - }); - }); - }); - - describe('.dissociate()', () => { - it('should remove an image from the post\'s maintained list of uploads', (done) => { - async.waterfall([ - async.apply(posts.uploads.dissociate, pid, 'whoa.gif'), - async.apply(posts.uploads.list, pid), - ], (err, uploads) => { - assert.ifError(err); - assert.strictEqual(4, uploads.length); - assert.strictEqual(false, uploads.includes('whoa.gif')); - done(); - }); - }); - - it('should allow arrays to be passed in', (done) => { - async.waterfall([ - async.apply(posts.uploads.dissociate, pid, ['amazeballs.jpg', 'wut.txt']), - async.apply(posts.uploads.list, pid), - ], (err, uploads) => { - assert.ifError(err); - assert.strictEqual(2, uploads.length); - assert.strictEqual(false, uploads.includes('amazeballs.jpg')); - assert.strictEqual(false, uploads.includes('wut.txt')); - done(); - }); - }); - }); - - describe('.dissociateAll()', () => { - it('should remove all images from a post\'s maintained list of uploads', async () => { - await posts.uploads.dissociateAll(pid); - const uploads = await posts.uploads.list(pid); - - assert.equal(uploads.length, 0); - }); - }); - - describe('Dissociation on purge', () => { - it('should not dissociate images on post deletion', async () => { - await posts.delete(purgePid, 1); - const uploads = await posts.uploads.list(purgePid); - - assert.equal(uploads.length, 2); - }); - - it('should dissociate images on post purge', async () => { - await posts.purge(purgePid, 1); - const uploads = await posts.uploads.list(purgePid); - - assert.equal(uploads.length, 0); - }); - }); - }); - - describe('post uploads management', () => { - let topic; - let reply; - before((done) => { - topics.post({ - uid: 1, - cid: cid, - title: 'topic to test uploads with', - content: '[abcdef](/assets/uploads/files/abracadabra.png)', - }, (err, topicPostData) => { - assert.ifError(err); - topics.reply({ - uid: 1, - tid: topicPostData.topicData.tid, - timestamp: Date.now(), - content: '[abcdef](/assets/uploads/files/shazam.jpg)', - }, (err, replyData) => { - assert.ifError(err); - topic = topicPostData; - reply = replyData; - done(); - }); - }); - }); - - it('should automatically sync uploads on topic create and reply', (done) => { - db.sortedSetsCard([`post:${topic.topicData.mainPid}:uploads`, `post:${reply.pid}:uploads`], (err, lengths) => { - assert.ifError(err); - assert.strictEqual(1, lengths[0]); - assert.strictEqual(1, lengths[1]); - done(); - }); - }); - - it('should automatically sync uploads on post edit', (done) => { - async.waterfall([ - async.apply(posts.edit, { - pid: reply.pid, - uid: 1, - content: 'no uploads', - }), - function (postData, next) { - posts.uploads.list(reply.pid, next); - }, - ], (err, uploads) => { - assert.ifError(err); - assert.strictEqual(true, Array.isArray(uploads)); - assert.strictEqual(0, uploads.length); - done(); - }); - }); - }); - describe('Topic Backlinks', () => { let tid1; before(async () => { @@ -1481,3 +1227,17 @@ describe('Post\'s', () => { }); }); }); + +describe('Posts\'', async () => { + let files; + + before(async () => { + files = await file.walk(path.resolve(__dirname, './posts')); + }); + + it('subfolder tests', () => { + files.forEach((filePath) => { + require(filePath); + }); + }); +}); diff --git a/test/posts/uploads.js b/test/posts/uploads.js new file mode 100644 index 0000000000..8067e4cd15 --- /dev/null +++ b/test/posts/uploads.js @@ -0,0 +1,417 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const nconf = require('nconf'); +const async = require('async'); +const crypto = require('crypto'); + +const db = require('../mocks/databasemock'); + +const categories = require('../../src/categories'); +const topics = require('../../src/topics'); +const posts = require('../../src/posts'); +const user = require('../../src/user'); +const meta = require('../../src/meta'); +const file = require('../../src/file'); +const utils = require('../../public/src/utils'); + +const _filenames = ['abracadabra.png', 'shazam.jpg', 'whoa.gif', 'amazeballs.jpg', 'wut.txt', 'test.bmp']; +const _recreateFiles = () => { + // Create stub files for testing + _filenames.forEach(filename => fs.closeSync(fs.openSync(path.join(nconf.get('upload_path'), 'files', filename), 'w'))); +}; + +describe('upload methods', () => { + let pid; + let purgePid; + let cid; + let uid; + + before(async () => { + _recreateFiles(); + + uid = await user.create({ + username: 'uploads user', + password: 'abracadabra', + gdpr_consent: 1, + }); + + ({ cid } = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + })); + + const topicPostData = await topics.post({ + uid, + cid, + title: 'topic with some images', + content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png) and another [alt text](/assets/uploads/files/shazam.jpg)', + }); + pid = topicPostData.postData.pid; + + const purgePostData = await topics.post({ + uid, + cid, + title: 'topic with some images, to be purged', + content: 'here is an image [alt text](/assets/uploads/files/whoa.gif) and another [alt text](/assets/uploads/files/amazeballs.jpg)', + }); + purgePid = purgePostData.postData.pid; + }); + + describe('.sync()', () => { + it('should properly add new images to the post\'s zset', (done) => { + posts.uploads.sync(pid, (err) => { + assert.ifError(err); + + db.sortedSetCard(`post:${pid}:uploads`, (err, length) => { + assert.ifError(err); + assert.strictEqual(length, 2); + done(); + }); + }); + }); + + it('should remove an image if it is edited out of the post', (done) => { + async.series([ + function (next) { + posts.edit({ + pid: pid, + uid, + content: 'here is an image [alt text](/assets/uploads/files/abracadabra.png)... AND NO MORE!', + }, next); + }, + async.apply(posts.uploads.sync, pid), + ], (err) => { + assert.ifError(err); + db.sortedSetCard(`post:${pid}:uploads`, (err, length) => { + assert.ifError(err); + assert.strictEqual(1, length); + done(); + }); + }); + }); + }); + + describe('.list()', () => { + it('should display the uploaded files for a specific post', (done) => { + posts.uploads.list(pid, (err, uploads) => { + assert.ifError(err); + assert.equal(true, Array.isArray(uploads)); + assert.strictEqual(1, uploads.length); + assert.equal('string', typeof uploads[0]); + done(); + }); + }); + }); + + describe('.isOrphan()', () => { + it('should return false if upload is not an orphan', (done) => { + posts.uploads.isOrphan('files/abracadabra.png', (err, isOrphan) => { + assert.ifError(err); + assert.equal(isOrphan, false); + done(); + }); + }); + + it('should return true if upload is an orphan', (done) => { + posts.uploads.isOrphan('files/shazam.jpg', (err, isOrphan) => { + assert.ifError(err); + assert.equal(true, isOrphan); + done(); + }); + }); + }); + + describe('.associate()', () => { + it('should add an image to the post\'s maintained list of uploads', (done) => { + async.waterfall([ + async.apply(posts.uploads.associate, pid, 'files/whoa.gif'), + async.apply(posts.uploads.list, pid), + ], (err, uploads) => { + assert.ifError(err); + assert.strictEqual(2, uploads.length); + assert.strictEqual(true, uploads.includes('files/whoa.gif')); + done(); + }); + }); + + it('should allow arrays to be passed in', (done) => { + async.waterfall([ + async.apply(posts.uploads.associate, pid, ['files/amazeballs.jpg', 'files/wut.txt']), + async.apply(posts.uploads.list, pid), + ], (err, uploads) => { + assert.ifError(err); + assert.strictEqual(4, uploads.length); + assert.strictEqual(true, uploads.includes('files/amazeballs.jpg')); + assert.strictEqual(true, uploads.includes('files/wut.txt')); + done(); + }); + }); + + it('should save a reverse association of md5sum to pid', (done) => { + const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); + + async.waterfall([ + async.apply(posts.uploads.associate, pid, ['files/test.bmp']), + function (next) { + db.getSortedSetRange(`upload:${md5('files/test.bmp')}:pids`, 0, -1, next); + }, + ], (err, pids) => { + assert.ifError(err); + assert.strictEqual(true, Array.isArray(pids)); + assert.strictEqual(true, pids.length > 0); + assert.equal(pid, pids[0]); + done(); + }); + }); + + it('should not associate a file that does not exist on the local disk', (done) => { + async.waterfall([ + async.apply(posts.uploads.associate, pid, ['files/nonexistant.xls']), + async.apply(posts.uploads.list, pid), + ], (err, uploads) => { + assert.ifError(err); + assert.strictEqual(uploads.length, 5); + assert.strictEqual(false, uploads.includes('files/nonexistant.xls')); + done(); + }); + }); + }); + + describe('.dissociate()', () => { + it('should remove an image from the post\'s maintained list of uploads', (done) => { + async.waterfall([ + async.apply(posts.uploads.dissociate, pid, 'files/whoa.gif'), + async.apply(posts.uploads.list, pid), + ], (err, uploads) => { + assert.ifError(err); + assert.strictEqual(4, uploads.length); + assert.strictEqual(false, uploads.includes('files/whoa.gif')); + done(); + }); + }); + + it('should allow arrays to be passed in', (done) => { + async.waterfall([ + async.apply(posts.uploads.dissociate, pid, ['files/amazeballs.jpg', 'files/wut.txt']), + async.apply(posts.uploads.list, pid), + ], (err, uploads) => { + assert.ifError(err); + assert.strictEqual(2, uploads.length); + assert.strictEqual(false, uploads.includes('files/amazeballs.jpg')); + assert.strictEqual(false, uploads.includes('files/wut.txt')); + done(); + }); + }); + + it('should remove the image\'s user association, if present', async () => { + _recreateFiles(); + await posts.uploads.associate(pid, 'files/wut.txt'); + await user.associateUpload(uid, 'files/wut.txt'); + await posts.uploads.dissociate(pid, 'files/wut.txt'); + + const userUploads = await db.getSortedSetMembers(`uid:${uid}:uploads`); + assert.strictEqual(userUploads.includes('files/wut.txt'), false); + }); + }); + + describe('.dissociateAll()', () => { + it('should remove all images from a post\'s maintained list of uploads', async () => { + await posts.uploads.dissociateAll(pid); + const uploads = await posts.uploads.list(pid); + + assert.equal(uploads.length, 0); + }); + }); + + describe('Dissociation on purge', () => { + it('should not dissociate images on post deletion', async () => { + await posts.delete(purgePid, 1); + const uploads = await posts.uploads.list(purgePid); + + assert.equal(uploads.length, 2); + }); + + it('should dissociate images on post purge', async () => { + await posts.purge(purgePid, 1); + const uploads = await posts.uploads.list(purgePid); + + assert.equal(uploads.length, 0); + }); + }); + + describe('Deletion from disk on purge', () => { + let postData; + + beforeEach(async () => { + _recreateFiles(); + + ({ postData } = await topics.post({ + uid, + cid, + title: 'Testing deletion from disk on purge', + content: 'these images: ![alt text](/assets/uploads/files/abracadabra.png) and another ![alt text](/assets/uploads/files/test.bmp)', + })); + }); + + afterEach(async () => { + await topics.purge(postData.tid, uid); + }); + + it('should purge the images from disk if the post is purged', async () => { + await posts.purge(postData.pid, uid); + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'abracadabra.png')), false); + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'test.bmp')), false); + }); + + it('should leave the images behind if `preserveOrphanedUploads` is enabled', async () => { + meta.config.preserveOrphanedUploads = 1; + + await posts.purge(postData.pid, uid); + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'abracadabra.png')), true); + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'test.bmp')), true); + + delete meta.config.preserveOrphanedUploads; + }); + + it('should leave images behind if they are used in another post', async () => { + const { postData: secondPost } = await topics.post({ + uid, + cid, + title: 'Second topic', + content: 'just abracadabra: ![alt text](/assets/uploads/files/abracadabra.png)', + }); + + await posts.purge(secondPost.pid, uid); + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files', 'abracadabra.png')), true); + }); + }); + + describe('.deleteFromDisk()', () => { + beforeEach(() => { + _recreateFiles(); + }); + + it('should work if you pass in a string path', async () => { + await posts.uploads.deleteFromDisk('files/abracadabra.png'); + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files/abracadabra.png')), false); + }); + + it('should throw an error if a non-string or non-array is passed', async () => { + try { + await posts.uploads.deleteFromDisk({ + files: ['files/abracadabra.png'], + }); + } catch (err) { + assert(!!err); + assert.strictEqual(err.message, '[[error:wrong-parameter-type, filePaths, object, array]]'); + } + }); + + it('should delete the files passed in, from disk', async () => { + await posts.uploads.deleteFromDisk(['files/abracadabra.png', 'files/shazam.jpg']); + + const existsOnDisk = await Promise.all(_filenames.map(async (filename) => { + const fullPath = path.resolve(nconf.get('upload_path'), 'files', filename); + return file.exists(fullPath); + })); + + assert.deepStrictEqual(existsOnDisk, [false, false, true, true, true, true]); + }); + + it('should not delete files if they are not in `uploads/files/` (path traversal)', async () => { + const tmpFilePath = path.resolve(os.tmpdir(), `derp${utils.generateUUID()}`); + await fs.promises.appendFile(tmpFilePath, ''); + await posts.uploads.deleteFromDisk(['../files/503.html', tmpFilePath]); + + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), '../files/503.html')), true); + assert.strictEqual(await file.exists(tmpFilePath), true); + + await file.delete(tmpFilePath); + }); + + it('should delete files even if they are not orphans', async () => { + await topics.post({ + uid, + cid, + title: 'To be orphaned', + content: 'this image is not an orphan: ![wut](/assets/uploads/files/wut.txt)', + }); + + assert.strictEqual(await posts.uploads.isOrphan('files/wut.txt'), false); + await posts.uploads.deleteFromDisk(['files/wut.txt']); + + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), 'files/wut.txt')), false); + }); + }); +}); + +describe('post uploads management', () => { + let topic; + let reply; + let uid; + let cid; + + before(async () => { + _recreateFiles(); + + uid = await user.create({ + username: 'uploads user', + password: 'abracadabra', + gdpr_consent: 1, + }); + + ({ cid } = await categories.create({ + name: 'Test Category', + description: 'Test category created by testing script', + })); + + const topicPostData = await topics.post({ + uid, + cid, + title: 'topic to test uploads with', + content: '[abcdef](/assets/uploads/files/abracadabra.png)', + }); + + const replyData = await topics.reply({ + uid, + tid: topicPostData.topicData.tid, + timestamp: Date.now(), + content: '[abcdef](/assets/uploads/files/shazam.jpg)', + }); + + topic = topicPostData; + reply = replyData; + }); + + it('should automatically sync uploads on topic create and reply', (done) => { + db.sortedSetsCard([`post:${topic.topicData.mainPid}:uploads`, `post:${reply.pid}:uploads`], (err, lengths) => { + assert.ifError(err); + assert.strictEqual(lengths[0], 1); + assert.strictEqual(lengths[1], 1); + done(); + }); + }); + + it('should automatically sync uploads on post edit', (done) => { + async.waterfall([ + async.apply(posts.edit, { + pid: reply.pid, + uid, + content: 'no uploads', + }), + function (postData, next) { + posts.uploads.list(reply.pid, next); + }, + ], (err, uploads) => { + assert.ifError(err); + assert.strictEqual(true, Array.isArray(uploads)); + assert.strictEqual(0, uploads.length); + done(); + }); + }); +}); diff --git a/test/topics.js b/test/topics.js index 8eeb821ce5..44587639bc 100644 --- a/test/topics.js +++ b/test/topics.js @@ -2830,7 +2830,7 @@ describe('Topic\'s', () => { }); }); -describe('Topics\'s', async () => { +describe('Topics\'', async () => { let files; before(async () => { diff --git a/test/topics/thumbs.js b/test/topics/thumbs.js index 58fb464595..8486947b76 100644 --- a/test/topics/thumbs.js +++ b/test/topics/thumbs.js @@ -182,13 +182,13 @@ describe('Topic thumbs', () => { it('should associate the thumbnail with that topic\'s main pid\'s uploads', async () => { const uploads = await posts.uploads.list(mainPid); - assert(uploads.includes(path.basename(relativeThumbPaths[0]))); + assert(uploads.includes(relativeThumbPaths[0].slice(1))); }); it('should maintain state in the topic\'s main pid\'s uploads if posts.uploads.sync() is called', async () => { await posts.uploads.sync(mainPid); const uploads = await posts.uploads.list(mainPid); - assert(uploads.includes(path.basename(relativeThumbPaths[0]))); + assert(uploads.includes(relativeThumbPaths[0].slice(1))); }); it('should combine the thumbs uploaded to a UUID zset and combine it with a topic\'s thumb zset', async () => { @@ -217,7 +217,7 @@ describe('Topic thumbs', () => { }); describe(`.delete()`, () => { - it('should remove a file from sorted set AND disk', async () => { + it('should remove a file from sorted set', async () => { await topics.thumbs.associate({ id: 1, path: thumbPaths[0], @@ -225,7 +225,6 @@ describe('Topic thumbs', () => { await topics.thumbs.delete(1, relativeThumbPaths[0]); assert.strictEqual(await db.isSortedSetMember('topic:1:thumbs', relativeThumbPaths[0]), false); - assert.strictEqual(await file.exists(thumbPaths[0]), false); }); it('should no longer be associated with that topic\'s main pid\'s uploads', async () => { @@ -430,9 +429,9 @@ describe('Topic thumbs', () => { assert.strictEqual(await db.exists(`topic:${topicObj.tid}:thumbs`), false); }); - it('should not leave files behind', async () => { - const exists = await Promise.all(thumbPaths.slice(0, 2).map(async absolutePath => file.exists(absolutePath))); - assert.strictEqual(exists.some(Boolean), false); + it('should not leave post upload associations behind', async () => { + const uploads = await db.getSortedSetMembers(`post:${topicObj.postData.pid}:uploads`); + assert.strictEqual(uploads.length, 0); }); }); }); diff --git a/test/uploads.js b/test/uploads.js index 09b56fe64a..f299cba39e 100644 --- a/test/uploads.js +++ b/test/uploads.js @@ -130,7 +130,7 @@ describe('Upload Controllers', () => { assert(body && body.status && body.response && body.response.images); assert(Array.isArray(body.response.images)); assert(body.response.images[0].url); - const name = body.response.images[0].url.replace(nconf.get('relative_path') + nconf.get('upload_url'), ''); + const name = body.response.images[0].url.replace(`${nconf.get('relative_path') + nconf.get('upload_url')}/`, ''); socketUser.deleteUpload({ uid: regularUid }, { uid: regularUid, name: name }, (err) => { assert.ifError(err); db.getSortedSetRange(`uid:${regularUid}:uploads`, 0, -1, (err, uploads) => { diff --git a/test/user/uploads.js b/test/user/uploads.js new file mode 100644 index 0000000000..eee135c4de --- /dev/null +++ b/test/user/uploads.js @@ -0,0 +1,166 @@ +'use strict'; + +const assert = require('assert'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); + +const nconf = require('nconf'); +const db = require('../mocks/databasemock'); + +const user = require('../../src/user'); +const topics = require('../../src/topics'); +const categories = require('../../src/categories'); +const file = require('../../src/file'); +const utils = require('../../public/src/utils'); + +const md5 = filename => crypto.createHash('md5').update(filename).digest('hex'); + +describe('uploads.js', () => { + describe('.associateUpload()', () => { + let uid; + let relativePath; + + beforeEach(async () => { + uid = await user.create({ + username: utils.generateUUID(), + password: utils.generateUUID(), + gdpr_consent: 1, + }); + relativePath = `files/${utils.generateUUID()}`; + + fs.closeSync(fs.openSync(path.join(nconf.get('upload_path'), relativePath), 'w')); + }); + + it('should associate an uploaded file to a user', async () => { + await user.associateUpload(uid, relativePath); + const uploads = await db.getSortedSetMembers(`uid:${uid}:uploads`); + const uploadObj = await db.getObject(`upload:${md5(relativePath)}`); + + assert.strictEqual(uploads.length, 1); + assert.deepStrictEqual(uploads, [relativePath]); + assert.strictEqual(parseInt(uploadObj.uid, 10), uid); + }); + + it('should throw an error if the path is invalid', async () => { + try { + await user.associateUpload(uid, `${relativePath}suffix`); + } catch (e) { + assert(e); + assert.strictEqual(e.message, '[[error:invalid-path]]'); + } + + const uploads = await db.getSortedSetMembers(`uid:${uid}:uploads`); + + assert.strictEqual(uploads.length, 0); + assert.deepStrictEqual(uploads, []); + }); + + it('should guard against path traversal', async () => { + try { + await user.associateUpload(uid, `../../config.json`); + } catch (e) { + assert(e); + assert.strictEqual(e.message, '[[error:invalid-path]]'); + } + + const uploads = await db.getSortedSetMembers(`uid:${uid}:uploads`); + + assert.strictEqual(uploads.length, 0); + assert.deepStrictEqual(uploads, []); + }); + }); + + describe('.deleteUpload', () => { + let uid; + let relativePath; + + beforeEach(async () => { + uid = await user.create({ + username: utils.generateUUID(), + password: utils.generateUUID(), + gdpr_consent: 1, + }); + relativePath = `files/${utils.generateUUID()}`; + + fs.closeSync(fs.openSync(path.join(nconf.get('upload_path'), relativePath), 'w')); + await user.associateUpload(uid, relativePath); + }); + + it('should remove the upload from the user\'s uploads zset', async () => { + await user.deleteUpload(uid, uid, relativePath); + + const uploads = await db.getSortedSetMembers(`uid:${uid}:uploads`); + assert.deepStrictEqual(uploads, []); + }); + + it('should delete the file from disk', async () => { + let exists = await file.exists(`${nconf.get('upload_path')}/${relativePath}`); + assert.strictEqual(exists, true); + + await user.deleteUpload(uid, uid, relativePath); + + exists = await file.exists(`${nconf.get('upload_path')}/${relativePath}`); + assert.strictEqual(exists, false); + }); + + it('should clean up references to it from the database', async () => { + const hash = md5(relativePath); + let exists = await db.exists(`upload:${hash}`); + assert.strictEqual(exists, true); + + await user.deleteUpload(uid, uid, relativePath); + exists = await db.exists(`upload:${hash}`); + assert.strictEqual(exists, false); + }); + + it('should accept multiple paths', async () => { + const secondPath = `files/${utils.generateUUID()}`; + fs.closeSync(fs.openSync(path.join(nconf.get('upload_path'), secondPath), 'w')); + await user.associateUpload(uid, secondPath); + + assert.strictEqual(await db.sortedSetCard(`uid:${uid}:uploads`), 2); + + await user.deleteUpload(uid, uid, [relativePath, secondPath]); + + assert.strictEqual(await db.sortedSetCard(`uid:${uid}:uploads`), 0); + assert.deepStrictEqual(await db.getSortedSetMembers(`uid:${uid}:uploads`), []); + }); + + it('should throw an error on a non-existant file', async () => { + try { + await user.deleteUpload(uid, uid, `${relativePath}asdbkas`); + } catch (e) { + assert(e); + assert.strictEqual(e.message, '[[error:invalid-path]]'); + } + }); + + it('should guard against path traversal', async () => { + assert.strictEqual(await file.exists(path.resolve(nconf.get('upload_path'), '../../config.json')), true); + + try { + await user.deleteUpload(uid, uid, `../../config.json`); + } catch (e) { + assert(e); + assert.strictEqual(e.message, '[[error:invalid-path]]'); + } + }); + + it('should remove the post association as well, if present', async () => { + const { cid } = await categories.create({ name: utils.generateUUID() }); + const { postData } = await topics.post({ + uid, + cid, + title: utils.generateUUID(), + content: `[an upload](/assets/uploads/${relativePath})`, + }); + + assert.deepStrictEqual(await db.getSortedSetMembers(`upload:${md5(relativePath)}:pids`), [postData.pid.toString()]); + + await user.deleteUpload(uid, uid, relativePath); + + assert.strictEqual(await db.exists(`upload:${md5(relativePath)}:pids`), false); + }); + }); +});