Compare commits

..

2 Commits

Author SHA1 Message Date
Misty Release Bot
59bc2b0d4b chore: incrementing version number - v4.2.1 2025-04-10 14:03:46 +00:00
Barış Soner Uşaklı
33d50637a3 fix: closes #13317, fix email confirm for changing email 2025-04-10 09:53:20 -04:00
116 changed files with 602 additions and 1780 deletions

21
.eslintignore Normal file
View File

@@ -0,0 +1,21 @@
node_modules/
*.sublime-project
*.sublime-workspace
.project
.vagrant
.DS_Store
logs/
/public/templates
/public/uploads
/public/vendor
/public/src/modules/string.js
.idea/
.vscode/
*.ipr
*.iws
/coverage
/build
.eslintrc
test/files
*.min.js
install/docker/

3
.eslintrc Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "nodebb"
}

View File

@@ -1,62 +0,0 @@
'use strict';
const serverConfig = require('eslint-config-nodebb');
const publicConfig = require('eslint-config-nodebb/public');
const { configs } = require('@eslint/js');
const globals = require('globals');
module.exports = [
{
ignores: [
'node_modules/',
'.project',
'.vagrant',
'.DS_Store',
'.tx',
'logs/',
'public/uploads/',
'public/vendor/',
'.idea/',
'.vscode/',
'*.ipr',
'*.iws',
'coverage/',
'build/',
'test/files/',
'*.min.js',
'install/docker/',
],
},
configs.recommended,
{
rules: {
'no-bitwise': 'warn',
'no-await-in-loop': 'warn',
}
},
// tests
{
files: ['test/**/*.js'],
languageOptions: {
ecmaVersion: 2020,
sourceType: 'commonjs',
globals: {
...globals.node,
...globals.browser,
it: 'readonly',
describe: 'readonly',
before: 'readonly',
beforeEach: 'readonly',
after: 'readonly',
afterEach: 'readonly',
},
},
rules: {
'no-unused-vars': 'off',
'no-prototype-builtins': 'off',
}
},
...publicConfig,
...serverConfig
];

View File

@@ -2,7 +2,7 @@
"name": "nodebb",
"license": "GPL-3.0",
"description": "NodeBB Forum",
"version": "4.3.0-alpha.2",
"version": "4.2.1",
"homepage": "https://www.nodebb.org",
"repository": {
"type": "git",
@@ -39,13 +39,13 @@
"@textcomplete/contenteditable": "0.1.13",
"@textcomplete/core": "0.1.13",
"@textcomplete/textarea": "0.1.13",
"ace-builds": "1.39.1",
"ace-builds": "1.39.0",
"archiver": "7.0.1",
"async": "3.2.6",
"autoprefixer": "10.4.21",
"bcryptjs": "3.0.2",
"benchpressjs": "2.5.3",
"body-parser": "2.2.0",
"body-parser": "1.20.3",
"bootbox": "6.0.0",
"bootstrap": "5.3.3",
"bootswatch": "5.3.3",
@@ -53,7 +53,8 @@
"chart.js": "4.4.8",
"cli-graph": "3.2.2",
"clipboard": "2.0.11",
"commander": "13.1.0",
"colors": "1.4.0",
"commander": "12.1.0",
"compare-versions": "6.1.1",
"compression": "1.8.0",
"connect-flash": "0.1.1",
@@ -62,9 +63,9 @@
"connect-pg-simple": "10.0.0",
"connect-redis": "8.0.2",
"cookie-parser": "1.4.7",
"cron": "4.1.2",
"cron": "4.1.0",
"cropperjs": "1.6.2",
"csrf-sync": "4.1.0",
"csrf-sync": "4.0.3",
"daemon": "1.1.0",
"diff": "7.0.0",
"esbuild": "0.25.1",
@@ -98,12 +99,12 @@
"multiparty": "4.2.3",
"nconf": "0.12.1",
"nodebb-plugin-2factor": "7.5.9",
"nodebb-plugin-composer-default": "10.2.49",
"nodebb-plugin-composer-default": "10.2.47",
"nodebb-plugin-dbsearch": "6.2.13",
"nodebb-plugin-emoji": "6.0.2",
"nodebb-plugin-emoji-android": "4.1.1",
"nodebb-plugin-markdown": "13.1.1",
"nodebb-plugin-mentions": "4.7.2",
"nodebb-plugin-mentions": "4.7.1",
"nodebb-plugin-spam-be-gone": "2.3.1",
"nodebb-plugin-web-push": "0.7.3",
"nodebb-rewards-essentials": "1.0.1",
@@ -127,9 +128,9 @@
"rimraf": "5.0.10",
"rss": "1.2.2",
"rtlcss": "4.3.0",
"sanitize-html": "2.15.0",
"sanitize-html": "2.14.0",
"sass": "1.86.0",
"satori": "0.12.2",
"satori": "0.12.1",
"semver": "7.7.1",
"serve-favicon": "2.5.0",
"sharp": "0.32.6",
@@ -146,7 +147,7 @@
"tinycon": "0.6.8",
"toobusy-js": "0.5.1",
"tough-cookie": "5.1.2",
"validator": "13.15.0",
"validator": "13.12.0",
"webpack": "5.98.0",
"webpack-merge": "6.0.1",
"winston": "3.17.0",
@@ -161,8 +162,8 @@
"@commitlint/cli": "19.8.0",
"@commitlint/config-angular": "19.8.0",
"coveralls": "3.1.1",
"@eslint/js": "9.23.0",
"eslint-config-nodebb": "1.0.7",
"eslint": "8.57.1",
"eslint-config-nodebb": "0.2.1",
"eslint-plugin-import": "2.31.0",
"grunt": "1.6.1",
"grunt-contrib-watch": "1.1.0",
@@ -199,4 +200,4 @@
"url": "https://github.com/barisusakli"
}
]
}
}

3
public/.eslintrc Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "nodebb/public"
}

View File

@@ -22,7 +22,7 @@
"invalid-user-data": "Yanlış istifadəçi məlumatı",
"invalid-password": "Yanlış şifrə",
"invalid-login-credentials": "Yanlış giriş məlumatları",
"invalid-username-or-password": "Zəhmət olmasa istifadəçi adı və şifrənizi daxil edin",
"invalid-username-or-password": "Zəhmət olmasa həm istifadəçi adı, həm də şifrənizi göstərin",
"invalid-search-term": "Yanlış axtarış termini",
"invalid-url": "Yanlış URL",
"invalid-event": "Yanlış hadisə: %1",

View File

@@ -66,8 +66,8 @@
"alert.unfollow": "Siz artıq %1-i izləmirsiniz!",
"alert.follow": "İndi %1-i izləyirsiniz!",
"users": "İstifadəçilər",
"topics": "Mövzu",
"posts": "Yazı",
"topics": "Mövzular",
"posts": "Yazılar",
"x-posts": "<span class=\"formatted-number\">%1</span> yazı",
"x-topics": "<span class=\"formatted-number\">%1</span> mövzu",
"x-reputation": "<span class=\"formatted-number\">%1</span> reputasiya",
@@ -80,7 +80,7 @@
"upvoted": "Müsbət səs verildir",
"downvoters": "Mənfi səs verənlər",
"downvoted": "Mənfi səs verildi",
"views": "Baxış",
"views": "Baxışlar",
"posters": "Yazarlar",
"reputation": "Reputasiya",
"lastpost": "Son yazı",

View File

@@ -17,7 +17,7 @@
"chat.user-has-messaged-you": "%1 sizə mesaj göndərib.",
"chat.replying-to": "%1-ə cavab verilir",
"chat.see-all": "Bütün söhbətlər",
"chat.mark-all-read": "Oxunmuş et",
"chat.mark-all-read": "Hamısını oxumuş et",
"chat.no-messages": "Söhbət mesajı tarixçəsinə baxmaq üçün alıcı tərəfi seçin",
"chat.no-users-in-room": "Bu otaqda heç bir istifadəçi yoxdur",
"chat.recent-chats": "Son söhbətlər",

View File

@@ -2,7 +2,7 @@
"title": "Bildirişlər",
"no-notifs": "Yeni bildirişləriniz yoxdur",
"see-all": "Bütün bildirişlər",
"mark-all-read": "Oxunmuş et",
"mark-all-read": "Hamısını oxumuş et",
"back-to-home": "%1-ə qayıt",
"outgoing-link": ıxış linki",
"outgoing-link-message": "İndi %1-i tərk edirsiniz",

View File

@@ -1,8 +1,8 @@
{
"theme-name": "Harmony Theme",
"skins": "Örtüklər",
"collapse": "Yığmaq",
"expand": "Açmaq",
"collapse": "Hamısını yığ",
"expand": "Hamısını",
"sidebar-toggle": "Yan panel aç/bağla",
"login-register-to-search": "Axtarış etmək üçün daxil olun və ya qeydiyyatdan keçin.",
"settings.title": "Mövzu ayarları",

View File

@@ -17,7 +17,7 @@
"last-reply-time": "Son cavab",
"reply-options": "Cavab variantları",
"reply-as-topic": "Mövzu olaraq cavablandır",
"guest-login-reply": "🔑 Daxil ol",
"guest-login-reply": "Cavab yazmaq üçün daxil ol",
"login-to-view": "🔒 Görmək üçün daxil ol",
"edit": "Redaktə et",
"delete": "Sil",
@@ -51,8 +51,8 @@
"user-locked-topic-on": "%1 bu mövzunu %2-də kilidlədi",
"user-unlocked-topic-ago": "%1 bu mövzunu açdı %2",
"user-unlocked-topic-on": "%1 bu mövzunu %2-də açdı",
"user-pinned-topic-ago": "%1 bu mövzunu sabitlədi %2",
"user-pinned-topic-on": "%1 bu mövzunu %2-də sabitlədi",
"user-pinned-topic-ago": "% 1 bu mövzunu sabitlədi % 2",
"user-pinned-topic-on": "% 1 bu mövzunu % 2-də sabitlədi",
"user-unpinned-topic-ago": "%1 bu mövzunu sabitdən qaldırdı %2",
"user-unpinned-topic-on": "%1 bu mövzunu %2-də sabitdən qaldırdı",
"user-deleted-topic-ago": "%1 bu mövzunu sildi %2",

View File

@@ -83,7 +83,6 @@
"email-confirmed": "Email Confirmed",
"email-confirmed-message": "Thank you for validating your email. Your account is now fully activated.",
"email-confirm-error-message": "There was a problem validating your email address. Perhaps the code was invalid or has expired.",
"email-confirm-error-message-already-validated": "Your email address was already validated.",
"email-confirm-sent": "Confirmation email sent.",
"none": "None",

View File

@@ -1,14 +1,14 @@
{
"intro-lead": "¿Qué es Federación?",
"intro-body": "NodeBB puede comunicarse con otras instancias de NodeBB que lo soportan. Esto es conseguido a través de un prótocolo llamado <a href=\"https://activitypub.rocks/\">ActivityPub</a>. Si se habilita, NodeBB también será capaz de comunicarse con otras aplicaciones y sitios web que usan ActivityPub (ej. Mastodon, Peertube, etc.)",
"intro-lead": "What is Federation?",
"intro-body": "NodeBB is able to communicate with other NodeBB instances that support it. This is achieved through a protocol called <a href=\"https://activitypub.rocks/\">ActivityPub</a>. If enabled, NodeBB will also be able to communicate with other apps and websites that use ActivityPub (e.g. Mastodon, Peertube, etc.)",
"general": "General",
"pruning": "Purga de contenido",
"content-pruning": "Días para mantener contenido remoto",
"content-pruning-help": "Nota, remover contenido que ha tenido actividad (respuesta, un voto) será preservado. (0 para deshabilitar)",
"user-pruning": "Días para mantener en caché cuentas de usuarios remotos",
"user-pruning-help": "Cuentas de usuarios remotos solo serán purgadas si no tienen publicaciones. De lo contrario serán re-obtenidas. (0 para deshabilitar)",
"enabled": "Habilitar Federación",
"enabled-help": "Si se activa, esto habilitará a NodeBB comunicarse con todos los clientes que tengan habilitado ActivityPub en el fediverso.",
"pruning": "Content Pruning",
"content-pruning": "Days to keep remote content",
"content-pruning-help": "Note that remote content that has received engagement (a reply or a upvote/downvote) will be preserved. (0 for disabled)",
"user-pruning": "Days to cache remote user accounts",
"user-pruning-help": "Remote user accounts will only be pruned if they have no posts. Otherwise they will be re-retrieved. (0 for disabled)",
"enabled": "Enable Federation",
"enabled-help": "If enabled, will allow this NodeBB will be able to communicate with all Activitypub-enabled clients on the wider fediverse.",
"allowLoopback": "Allow loopback processing",
"allowLoopback-help": "Useful for debugging purposes only. You should probably leave this disabled.",

View File

@@ -12,16 +12,16 @@
"email": "Correo electrónico",
"confirm-email": "Confirmar correo electrónico",
"account-info": "Información de cuenta",
"admin-actions-label": "Acciones administrativas",
"admin-actions-label": "Administrative Actions",
"ban-account": "Banear cuenta",
"ban-account-confirm": "Quieres confirmar el baneo de este usuario?",
"unban-account": "Desbanear cuenta",
"mute-account": "Mutear cuenta",
"unmute-account": "Desmutear cuenta",
"mute-account": "Mute Account",
"unmute-account": "Unmute Account",
"delete-account": "Eliminar cuenta",
"delete-account-as-admin": "Eliminar<strong>cuenta</strong>",
"delete-content": "Eliminar<strong>contenido de la cuenta</strong>",
"delete-all": "Eliminar<strong>cuenta</strong> y <strong>contenido</strong>",
"delete-account-as-admin": "Delete <strong>Account</strong>",
"delete-content": "Delete Account <strong>Content</strong>",
"delete-all": "Delete <strong>Account</strong> and <strong>Content</strong>",
"delete-account-confirm": "¿Estás seguro de que quieres anonimizar tus publicaciones y eliminar tu cuenta?<br /><strong>Esta acción es irreversible y no podrás recuperar ninguno de tus datos.</strong><br /><br />Ingresa tu contraseña para confirmar que deseas eliminar esta cuenta.",
"delete-this-account-confirm": "Are you sure you want to delete this account while leaving its contents behind?<br /><strong>This action is irreversible, posts will be anonymized, and you will not be able to restore post associations with the deleted account</strong><br /><br />",
"delete-account-content-confirm": "Are you sure you want to delete this account's content (posts/topics/uploads)? <br /><strong>This action is irreversible and you will not be able to recover any data</strong><br /><br />",

View File

@@ -1,26 +1,26 @@
{
"intro-lead": "Что такое Федерация?",
"intro-body": "NodeBB может взаимодействовать с другими экземплярами NodeBB, которые его поддерживают. Это достигается с помощью протокола <a href=\"https://activitypub.rocks/\">ActivityPub</a>. Если он включен, NodeBB также сможет взаимодействовать с другими приложениями и веб-сайтами, которые используют ActivityPub (например, Mastodon, Peertube и т. д.)",
"general": "Общие",
"pruning": "Обрезка контента",
"content-pruning": "Дней для хранения удаленного контента",
"content-pruning-help": "Обратите внимание, что удаленный контент, который привлек внимание (ответ или голосование за/против), будет сохранен. (значение 0 отключено)",
"user-pruning": "Дней для кэширования учетных записей удаленных пользователей",
"user-pruning-help": "Учетные записи удаленных пользователей будут удалены только в том случае, если в них нет записей. В противном случае они будут восстановлены повторно. (значение 0 отключено)",
"enabled": "Включить федерацию",
"enabled-help": "Если этот параметр включен, NodeBB сможет взаимодействовать со всеми клиентами, поддерживающими Activitypub, в более широком федеративном пространстве.",
"allowLoopback": "Разрешить обработку обратной связи",
"allowLoopback-help": "Полезно только для отладки. Вероятно, вам следует оставить это отключенным.",
"intro-lead": "What is Federation?",
"intro-body": "NodeBB is able to communicate with other NodeBB instances that support it. This is achieved through a protocol called <a href=\"https://activitypub.rocks/\">ActivityPub</a>. If enabled, NodeBB will also be able to communicate with other apps and websites that use ActivityPub (e.g. Mastodon, Peertube, etc.)",
"general": "General",
"pruning": "Content Pruning",
"content-pruning": "Days to keep remote content",
"content-pruning-help": "Note that remote content that has received engagement (a reply or a upvote/downvote) will be preserved. (0 for disabled)",
"user-pruning": "Days to cache remote user accounts",
"user-pruning-help": "Remote user accounts will only be pruned if they have no posts. Otherwise they will be re-retrieved. (0 for disabled)",
"enabled": "Enable Federation",
"enabled-help": "If enabled, will allow this NodeBB will be able to communicate with all Activitypub-enabled clients on the wider fediverse.",
"allowLoopback": "Allow loopback processing",
"allowLoopback-help": "Useful for debugging purposes only. You should probably leave this disabled.",
"probe": "Открыть в приложении",
"probe-enabled": "Попробуйте открыть ресурсы с поддержкой ActivityPub в NodeBB",
"probe-enabled-help": "Если включено, NodeBB будет проверять каждую внешнюю ссылку на наличие эквивалента ActivityPub и загружать его в NodeBB.",
"probe-timeout": "Время ожидания поиска (миллисекунды)",
"probe-timeout-help": "(По умолчанию: 2000) Если поисковый запрос не получит ответа в установленные сроки, пользователь будет перенаправлен непосредственно по ссылке. Увеличьте это число, если сайты отвечают медленно и вы хотите предоставить дополнительное время.",
"probe": "Open in App",
"probe-enabled": "Try to open ActivityPub-enabled resources in NodeBB",
"probe-enabled-help": "If enabled, NodeBB will check every external link for an ActivityPub equivalent, and load it in NodeBB instead.",
"probe-timeout": "Lookup Timeout (milliseconds)",
"probe-timeout-help": "(Default: 2000) If the lookup query does not receive a response within the set timeframe, will send the user to the link directly instead. Adjust this number higher if sites are responding slowly and you wish to give extra time.",
"server-filtering": "Фильтрация",
"count": "В настоящее время NodeBB знает о <strong>%1</strong> сервере(ах)",
"server.filter-help": "Укажите серверы, для которых вы хотели бы запретить объединение с вашим NodeBB. В качестве альтернативы вы можете выборочно <em>разрешить</em> объединение с определенными серверами. Поддерживаются оба варианта, хотя они и являются взаимоисключающими.",
"server.filter-help-hostname": "Введите ниже только имя хоста экземпляра (например, <code>example.org</code>), разделенное переносами строк.",
"server.filter-allow-list": "Вместо этого используйте это как список разрешений."
"server-filtering": "Filtering",
"count": "This NodeBB is currently aware of <strong>%1</strong> server(s)",
"server.filter-help": "Specify servers you would like to bar from federating with your NodeBB. Alternatively, you may opt to selectively <em>allow</em> federation with specific servers, instead. Both options are supported, although they are mutually exclusive.",
"server.filter-help-hostname": "Enter just the instance hostname below (e.g. <code>example.org</code>), separated by line breaks.",
"server.filter-allow-list": "Use this as an Allow List instead"
}

View File

@@ -1,9 +1,9 @@
{
"category": "Категория",
"subcategories": "Подкатегории",
"uncategorized": "Без рубрики",
"uncategorized.description": "Темы, которые не вписываются строго ни в одну из существующих категорий",
"handle.description": "За этой категорией можно следить из открытой социальной сети, используя идентификатор %1",
"uncategorized": "Uncategorized",
"uncategorized.description": "Topics that do not strictly fit in with any existing categories",
"handle.description": "This category can be followed from the open social web via the handle %1",
"new-topic-button": "Создать тему",
"guest-login-post": "Авторизуйтесь, чтобы написать сообщение",
"no-topics": "<strong>В этой категории еще нет тем.</strong><br />Почему бы вам не создать первую?",
@@ -13,15 +13,15 @@
"watch": "Отслеживать",
"ignore": "Игнорировать",
"watching": "Отслеживается",
"tracking": "Отслеживание",
"tracking": "Tracking",
"not-watching": "Не отслеживается",
"ignoring": "Игнорируется",
"watching.description": "Уведомляйте меня о новых темах.<br/>Показывать темы в непрочитанных и недавних",
"tracking.description": "Показывает темы в непрочитанных и недавних",
"watching.description": "Notify me of new topics.<br/>Show topics in unread & recent",
"tracking.description": "Shows topics in unread & recent",
"not-watching.description": "Не показывать темы из этой категории в непрочитанных, но оставить в списке недавних",
"ignoring.description": "Не показывать темы в непрочитанных и недавних",
"ignoring.description": "Do not show topics in unread & recent",
"watching.message": "Вы отслеживаете обновления этой категории, включая все подкатегории",
"tracking.message": "Теперь вы отслеживаете обновления в этой категории и всех подкатегориях",
"tracking.message": "You are now tracking updates from this category and all subcategories",
"notwatching.message": "Вы более не отслеживаете обновления этой категории, включая все подкатегории",
"ignoring.message": "Вы игнорируете обновления этой категории, включая все подкатегории",
"watched-categories": "Отслеживаемые категории",

View File

@@ -1,21 +1,21 @@
{
"chat.room-id": "Room %1",
"chat.chatting-with": "Чат с",
"chat.placeholder": "Введите сообщение чата здесь, перетащите изображения",
"chat.placeholder.mobile": "Введите сообщение чата",
"chat.placeholder.message-room": "Сообщение #%1",
"chat.placeholder": "Type chat message here, drag & drop images",
"chat.placeholder.mobile": "Type chat message",
"chat.placeholder.message-room": "Message #%1",
"chat.scroll-up-alert": "Go to most recent message",
"chat.usernames-and-x-others": "%1 пользователей и %2 других",
"chat.chat-with-usernames": "Чат с %1",
"chat.chat-with-usernames-and-x-others": "Чат с %1 и %2 других",
"chat.send": "Отправить",
"chat.no-active": "У вас нет активных чатов.",
"chat.user-typing-1": "<strong>% 1</strong> набирает текст ...",
"chat.user-typing-2": "<strong>%1</strong> и <strong>%2</strong> печатают ...",
"chat.user-typing-3": "<strong>%1</strong>, <strong>%2</strong> и <strong>%3</strong> печатают ...",
"chat.user-typing-n": "<strong>%1</strong>, <strong>%2</strong> и <strong>%3</strong> другие печатают ...",
"chat.user-typing-1": "<strong>%1</strong> is typing ...",
"chat.user-typing-2": "<strong>%1</strong> and <strong>%2</strong> are typing ...",
"chat.user-typing-3": "<strong>%1</strong>, <strong>%2</strong> and <strong>%3</strong> are typing ...",
"chat.user-typing-n": "<strong>%1</strong>, <strong>%2</strong> and <strong>%3</strong> others are typing ...",
"chat.user-has-messaged-you": "Пользователь %1 отправил вам сообщение.",
"chat.replying-to": "Отвечаю %1",
"chat.replying-to": "Replying to %1",
"chat.see-all": "Все чаты",
"chat.mark-all-read": "Пометить как прочитанное",
"chat.no-messages": "Пожалуйста, выберите собеседника для просмотра истории сообщений",
@@ -33,49 +33,49 @@
"chat.three-months": "3 месяца",
"chat.delete-message-confirm": "Вы уверены, что хотите удалить это сообщение?",
"chat.retrieving-users": "Получение списка пользователей...",
"chat.view-users-list": "Просмотр списка пользователей",
"chat.pinned-messages": "Закрепленные сообщения",
"chat.no-pinned-messages": "Закрепленных сообщений нет",
"chat.pin-message": "Закрепить сообщение",
"chat.unpin-message": "Открепить сообщение",
"chat.public-rooms": "Открытые комнаты (%1)",
"chat.private-rooms": "Приватные комнаты (%1)",
"chat.create-room": "Создать чат комнату",
"chat.private.option": "Приватный (виден только пользователям, добавленным в комнату)",
"chat.public.option": "Публичный (виден каждому пользователю в выбранных группах)",
"chat.public.groups-help": "Чтобы создать чат-комнату, видимую всем пользователям, выберите зарегистрированных пользователей из списка групп.",
"chat.view-users-list": "View users list",
"chat.pinned-messages": "Pinned Messages",
"chat.no-pinned-messages": "There are no pinned messages",
"chat.pin-message": "Pin Message",
"chat.unpin-message": "Unpin Message",
"chat.public-rooms": "Public Rooms (%1)",
"chat.private-rooms": "Private Rooms (%1)",
"chat.create-room": "Create Chat Room",
"chat.private.option": "Private (Only visible to users added to room)",
"chat.public.option": "Public (Visible to every user in selected groups)",
"chat.public.groups-help": "To create a chat room that is visible to all users select registered-users from the group list.",
"chat.manage-room": "Управлять комнатой чата",
"chat.add-user": "Добавить пользователя",
"chat.notification-settings": "Настройки уведомлений",
"chat.default-notification-setting": "Настройка уведомлений по умолчанию",
"chat.notification-setting-room-default": "Комната по умолчанию",
"chat.notification-setting-none": "Нет уведомлений",
"chat.notification-setting-at-mention-only": "только @упоминание",
"chat.notification-setting-all-messages": "Все сообщения",
"chat.select-groups": "Выбрать группы",
"chat.add-user": "Add User",
"chat.notification-settings": "Notification Settings",
"chat.default-notification-setting": "Default Notification Setting",
"chat.notification-setting-room-default": "Room Default",
"chat.notification-setting-none": "No notifications",
"chat.notification-setting-at-mention-only": "@mention only",
"chat.notification-setting-all-messages": "All messages",
"chat.select-groups": "Select Groups",
"chat.add-user-help": "Поиск пользователей здесь. Когда выбрали пользователя, он будет добавлен в чат. Новый пользователь не сможет видеть сообщения чата, написанные до его добавления в беседу. Только владельцы комнат (<i class=\"fa fa-star text-warning\"></i>) могут удалить пользователей из чатов.",
"chat.confirm-chat-with-dnd-user": "Этот пользователь установил статус \"Не беспокоить\". Вы всё еще хотите написать ему?",
"chat.room-name-optional": "Название комнаты (необязательно)",
"chat.room-name-optional": "Room Name (Optional)",
"chat.rename-room": "Переименовать комнату",
"chat.rename-placeholder": "Введите название комнаты здесь",
"chat.rename-help": "Название комнаты, установленное здесь, будет доступно для просмотра всеми участниками комнаты.",
"chat.leave": "Покинуть",
"chat.leave-room": "Покинуть комнату",
"chat.leave": "Leave",
"chat.leave-room": "Leave Room",
"chat.leave-prompt": "Вы действительно хотите покинуть чат?",
"chat.leave-help": "Оставив этот чат, вы удалите себя из будущей переписки в этом чате. Если вы будете повторно добавлены в будущем, вы не увидите истории чата до вашего повторного присоединения.",
"chat.delete": "Удалить",
"chat.delete-room": "Удалить комнату",
"chat.delete-prompt": "Вы уверены, что хотите удалить этот чат?",
"chat.delete": "Delete",
"chat.delete-room": "Delete Room",
"chat.delete-prompt": "Are you sure you wish to delete this chat room?",
"chat.in-room": "В этой комнате",
"chat.kick": "Исключить",
"chat.show-ip": "Показать IP",
"chat.copy-text": "Копировать текст",
"chat.copy-link": "Копировать ссылку",
"chat.copy-text": "Copy Text",
"chat.copy-link": "Copy Link",
"chat.owner": "Владелец комнаты",
"chat.grant-rescind-ownership": "Предоставление/отмена права собственности",
"chat.system.user-join": "%1 присоединился к комнате <span class=\"timeago\" title=\"%2\"></span>",
"chat.system.user-leave": "% 1 покинул комнату <span class=\"timeago\" title=\"%2\"></span>",
"chat.system.room-rename": "%2 переименовал эту комнату в \"%1\" <span class=\"timeago\" title=\"%3\"></span>",
"chat.grant-rescind-ownership": "Grant/Rescind Ownership",
"chat.system.user-join": "%1 has joined the room <span class=\"timeago\" title=\"%2\"></span>",
"chat.system.user-leave": "%1 has left the room <span class=\"timeago\" title=\"%2\"></span>",
"chat.system.room-rename": "%2 has renamed this room to \"%1\" <span class=\"timeago\" title=\"%3\"></span>",
"composer.compose": "Редактор сообщений",
"composer.show-preview": "Показать предпросмотр сообщения",
"composer.hide-preview": "Скрыть предпросмотр",
@@ -88,13 +88,13 @@
"composer.uploading": "Загрузка %1",
"composer.formatting.bold": "Жирный",
"composer.formatting.italic": "Курсив",
"composer.formatting.heading": "Заголовок",
"composer.formatting.heading1": "Заголовок 1",
"composer.formatting.heading2": "Заголовок 2",
"composer.formatting.heading3": "Заголовок 3",
"composer.formatting.heading4": "Заголовок 4",
"composer.formatting.heading5": "Заголовок 5",
"composer.formatting.heading6": "Заголовок 6",
"composer.formatting.heading": "Heading",
"composer.formatting.heading1": "Heading 1",
"composer.formatting.heading2": "Heading 2",
"composer.formatting.heading3": "Heading 3",
"composer.formatting.heading4": "Heading 4",
"composer.formatting.heading5": "Heading 5",
"composer.formatting.heading6": "Heading 6",
"composer.formatting.list": "Список",
"composer.formatting.strikethrough": "Зачеркнуть",
"composer.formatting.code": "Код",
@@ -105,18 +105,18 @@
"composer.zen-mode": "Полноэкранный режим",
"composer.select-category": "Выберите категорию",
"composer.textarea.placeholder": "Введите содержание вашего сообщения здесь, перетащите изображения",
"composer.post-queue-alert": "Здравствуйте! <br/> На этом форуме используется система очереди сообщений, поскольку вы новый пользователь, ваше сообщение будет скрыто до тех пор, пока оно не будет одобрено нашей командой модераторов.",
"composer.post-queue-alert": "Hello👋!<br/>This forum uses a post queue system, since you are a new user your post will be hidden until it is approved by our moderation team.",
"composer.schedule-for": "Установить дату публикации",
"composer.schedule-date": "Дата",
"composer.schedule-time": "Время",
"composer.cancel-scheduling": "Отменить отложенную публикацию",
"composer.change-schedule-date": "Изменить дату",
"composer.change-schedule-date": "Change Date",
"composer.set-schedule-date": "Установить дату",
"composer.discard-all-drafts": "Удалить все черновики",
"composer.no-drafts": "У вас нет черновиков",
"composer.discard-draft-confirm": "Удалить все черновики?",
"composer.remote-pid-editing": "Редактирование удаленного поста",
"composer.remote-pid-content-immutable": "Содержание удаленных сообщений не может быть отредактировано. Однако вы можете изменить название темы и теги.",
"composer.remote-pid-editing": "Editing a remote post",
"composer.remote-pid-content-immutable": "The content of remote posts cannot be edited. However, you are able change the topic title and tags.",
"bootbox.ok": "ОК",
"bootbox.cancel": "Отмена",
"bootbox.confirm": "Подтвердить",

View File

@@ -8,6 +8,6 @@
"no-recent-topics": "Нет свежих тем.",
"no-popular-topics": "Популярные темы отсутствуют.",
"load-new-posts": "Загрузить новые сообщения",
"uncategorized.title": "Все известные темы",
"uncategorized.intro": "На этой странице представлен хронологический список всех тем, обсуждавшихся на этом форуме.<br /> Мнения и мнения, высказанные в приведенных ниже темах, не модерируются и могут не отражать взгляды и мнения данного веб-сайта."
"uncategorized.title": "All known topics",
"uncategorized.intro": "This page shows a chronological listing of every topic that this forum has received.<br />The views and opinions expressed in the topics below are not moderated and may not represent the views and opinions of this website."
}

View File

@@ -1,6 +1,6 @@
{
"register": "Регистрация",
"already-have-account": "У вас уже есть учетная запись?",
"already-have-account": "Already have an account?",
"cancel-registration": "Отменить регистрацию",
"help.email": "Ваш адрес электронной почты будет скрыт от других пользователей.",
"help.username-restrictions": "Другие пользователи смогут упоминать вас в своих сообщениях таким образом: @<span id='yourUsername'>никнейм</span>. Длина имени пользователя: %1-%2 символов.",
@@ -19,15 +19,15 @@
"agree-to-terms-of-use": "Я соглашаюсь с условиями",
"terms-of-use-error": "Для регистрации на нашем сайте необходимо согласиться с условиями",
"registration-added-to-queue": "Ваша регистрация была добавлена в очередь на утверждение. Вы получите уведомление по электронной почте, когда она будет одобрена администратором.",
"registration-queue-average-time": "Среднее время одобрения членства составляет %1 часа %2 минут.",
"registration-queue-auto-approve-time": "Ваше членство на этом форуме будет полностью активировано в течение %1 часов.",
"interstitial.intro": "Нам нужна дополнительная информация, чтобы обновить вашу учетную запись;",
"interstitial.intro-new": "Нам нужна дополнительная информация, прежде чем мы сможем создать вашу учётную запись…",
"interstitial.errors-found": "Пожалуйста, ознакомьтесь с введенной информацией:",
"registration-queue-average-time": "Our average time for approving memberships is %1 hours %2 minutes.",
"registration-queue-auto-approve-time": "Your membership to this forum will be fully activated in up to %1 hours.",
"interstitial.intro": "We'd like some additional information in order to update your account&hellip;",
"interstitial.intro-new": "We'd like some additional information before we can create your account&hellip;",
"interstitial.errors-found": "Please review the entered information:",
"gdpr-agree-data": "Я соглашаюсь на сбор и обработку моей личной информации на этом веб-сайте.",
"gdpr-agree-email": "Я соглашаюсь получать дайджесты и уведомления с этого сайта на свой адрес электронной почты.",
"gdpr-consent-denied": "Вы должны дать согласие на сбор, обработку вашей информации и отправку вам сообщений по электронной почте.",
"invite.error-admin-only": "Прямая регистрация пользователей была отключена. Пожалуйста, свяжитесь с администратором для получения более подробной информации.",
"invite.error-invite-only": "Прямая регистрация пользователей отключена. Для доступа к этому форуму вы должны быть приглашены существующим пользователем.",
"invite.error-invalid-data": "Полученные регистрационные данные не соответствуют нашим записям. Пожалуйста, свяжитесь с администратором для получения более подробной информации."
"invite.error-admin-only": "Direct user registration has been disabled. Please contact an administrator for more details.",
"invite.error-invite-only": "Direct user registration has been disabled. You must be invited by an existing user in order to access this forum.",
"invite.error-invalid-data": "The registration data received does not correspond to our records. Please contact an administrator for more details"
}

View File

@@ -1,9 +1,9 @@
{
"user-menu": "Меню пользователя",
"user-menu": "User menu",
"banned": "Заблокирован",
"unbanned": "Разбанен",
"muted": "Заглушенный",
"unmuted": "Без звука",
"unbanned": "Unbanned",
"muted": "Muted",
"unmuted": "Unmuted",
"offline": "Не в сети",
"deleted": "Удалён",
"username": "Имя пользователя",
@@ -16,8 +16,8 @@
"ban-account": "Заблокировать учётную запись",
"ban-account-confirm": "Вы действительно хотите заблокировать этого пользователя?",
"unban-account": "Разблокировать учётную запись",
"mute-account": "Отключение учетной записи",
"unmute-account": "Включить учетную запись",
"mute-account": "Mute Account",
"unmute-account": "Unmute Account",
"delete-account": "Удалить учётную запись",
"delete-account-as-admin": "Удалить <strong>учётную запись</strong>",
"delete-content": "Удалить <strong>контент</strong> учетной записи",
@@ -39,15 +39,15 @@
"reputation": "Репутация",
"bookmarks": "Закладки",
"watched-categories": "Отслеживаемые категории",
"watched-tags": "Просматриваемые теги",
"watched-tags": "Watched tags",
"change-all": "Изменить для всех",
"watched": "Отслеживаемые темы",
"ignored": "Игнорируемые темы",
"read": "Прочитать",
"read": "Read",
"default-category-watch-state": "Стандартная настройка отслеживания категорий",
"followers": "Подписчики",
"following": "Подписки",
"shares": "Поделиться",
"shares": "Shares",
"blocks": "Чёрный список",
"blocked-users": "Заблокированные пользователи",
"block-toggle": "Блок./Разблок",
@@ -59,18 +59,18 @@
"chat": "Чат",
"chat-with": "Продолжить чат с %1",
"new-chat-with": "Начать новый чат с %1",
"view-remote": "Посмотреть оригинал",
"view-remote": "View Original",
"flag-profile": "Пожаловаться на профиль",
"profile-flagged": "Уже отмечено",
"profile-flagged": "Already flagged",
"follow": "Подписаться",
"unfollow": "Отписаться",
"cancel-follow": "Отменить запрос на подписку",
"cancel-follow": "Cancel follow request",
"more": "Больше",
"profile-update-success": "Профиль обновлён!",
"change-picture": "Изменить аватар",
"change-username": "Изменить имя пользователя",
"change-email": "Изменить электронную почту",
"email-updated": "Электронная почта обновлена",
"email-updated": "Email Updated",
"email-same-as-password": "Пожалуйста, введите пароль, чтобы продолжить &ndash; вы снова указали свой новый адрес электронной почты",
"edit": "Редактировать",
"edit-profile": "Редактировать профиль",
@@ -79,11 +79,11 @@
"upload-new-picture": "Загрузить новый аватар",
"upload-new-picture-from-url": "Загрузить изображение по ссылке",
"current-password": "Текущий пароль",
"new-password": "Новый пароль",
"new-password": "New Password",
"change-password": "Изменить пароль",
"change-password-error": "Неправильный пароль!",
"change-password-error-wrong-current": "Текущий пароль указан неверно!",
"change-password-error-same-password": "Ваш новый пароль совпадает с вашим текущим паролем, пожалуйста, используйте новый пароль.",
"change-password-error-same-password": "Your new password matches your current password, please use a new password.",
"change-password-error-match": "Пароли должны совпадать!",
"change-password-error-privileges": "Вы не можете изменить пароль.",
"change-password-success": "Ваш пароль изменён!",
@@ -110,28 +110,28 @@
"digest-off": "Отключена",
"digest-daily": "Ежедневная",
"digest-weekly": "Еженедельная",
"digest-biweekly": "Два раза в неделю",
"digest-biweekly": "Bi-Weekly",
"digest-monthly": "Ежемесячная",
"has-no-follower": "На этого пользователя никто не подписан :(",
"follows-no-one": "Этот пользователь ни на кого не подписан :(",
"has-no-posts": "Этот пользователь ещё ничего не написал.",
"has-no-best-posts": "У этого пользователя пока нет ни одной публикации, за которую бы он проголосовал.",
"has-no-best-posts": "This user does not have any upvoted posts yet.",
"has-no-topics": "Этот пользователь ещё не создал ни одной темы.",
"has-no-watched-topics": "Этот пользователь не отслеживает ни одной темы.",
"has-no-ignored-topics": "Этот пользователь не игнорирует ни одну тему.",
"has-no-read-topics": "Этот пользователь еще не прочитал ни одной темы.",
"has-no-read-topics": "This user hasn't read any topics yet.",
"has-no-upvoted-posts": "Этот пользователь ещё ни одному сообщению не поднимал рейтинг.",
"has-no-downvoted-posts": "Этот пользователь ещё ни одному сообщению не понижал рейтинг.",
"has-no-controversial-posts": "У этого пользователя пока нет сообщений с отрицательными оценками.",
"has-no-controversial-posts": "This user does not have any downvoted posts yet.",
"has-no-blocks": "Вы никого не заблокировали.",
"has-no-shares": "Этот пользователь не поделился ни одной темой.",
"has-no-shares": "This user has not shared any topics.",
"email-hidden": "Электронная почта скрыта",
"hidden": "скрыто",
"paginate-description": "Разбивать темы и сообщения на страницы, а не выводить бесконечным списком",
"topics-per-page": "Тем на странице",
"posts-per-page": "Сообщений на странице",
"category-topic-sort": "Сортировка по категориям и темам",
"topic-post-sort": "Сортировка сообщений по темам",
"category-topic-sort": "Category topic sort",
"topic-post-sort": "Topic post sort",
"max-items-per-page": "максимум %1",
"acp-language": "Язык панели администратора",
"notifications": "Уведомления",
@@ -152,19 +152,19 @@
"follow-topics-you-create": "Включать отслеживание всех тем, которые вы создаёте",
"grouptitle": "Значки групп",
"group-order-help": "Выберите группу и укажите порядок значков с помощью стрелок",
"show-group-title": "Показать название группы",
"hide-group-title": "Скрыть название группы",
"order-group-up": "Группа заказов вверх",
"order-group-down": "Группа заказов вниз",
"show-group-title": "Show group title",
"hide-group-title": "Hide group title",
"order-group-up": "Order group up",
"order-group-down": "Order group down",
"no-group-title": "Не показывать значок группы",
"select-skin": "Стиль",
"default": "По умолчанию (%1)",
"no-skin": "Нет скина",
"default": "Default (%1)",
"no-skin": "No Skin",
"select-homepage": "Настройка главной страницы",
"homepage": "Главная страница",
"homepage-description": "Выберите, на какую страницу вы будете попадать после авторизации и использовать как главную, или оставьте стандартную настройку.",
"custom-route": "Ваш маршрут для главной страницы",
"custom-route-help": "Введите здесь имя маршрута без предшествующей косой черты (например, «recent» или «category/2/general-discussion»).",
"custom-route-help": "Enter a route name here, without any preceding slash (e.g. \"recent\" or \"category/2/general-discussion\")",
"sso.title": "Сервисы единого входа",
"sso.associated": "Связан с",
"sso.not-associated": "Нажмите здесь, чтобы связать учётную запись с",
@@ -172,24 +172,24 @@
"sso.dissociate-confirm-title": "Подтверждение открепления",
"sso.dissociate-confirm": "Вы уверены, что хотите открепить свою учётную запись от %1?",
"info.latest-flags": "Последние жалобы",
"info.profile": "Профиль",
"info.post": "Пост",
"info.view-flag": "Посмотреть флаг",
"info.reported-by": "Сообщил:",
"info.profile": "Profile",
"info.post": "Post",
"info.view-flag": "View flag",
"info.reported-by": "Reported by:",
"info.no-flags": "Жалоб не найдено",
"info.ban-history": "История блокировок",
"info.no-ban-history": "Этот пользователь никогда не был заблокирован",
"info.banned-until": "Заблокирован до %1",
"info.banned-expiry": "Истечение",
"info.ban-expired": "Срок действия бана истек",
"info.ban-expired": "Ban expired",
"info.banned-permanently": "Заблокирован навсегда",
"info.banned-reason-label": "Причина",
"info.banned-no-reason": "Без объяснения причин.",
"info.mute-history": "Недавняя история отключения звука",
"info.no-mute-history": "Этот пользователь никогда не был отключен",
"info.muted-until": "Отключено до %1",
"info.muted-expiry": "Истечение срока действия",
"info.muted-no-reason": "Причина не указана.",
"info.mute-history": "Recent Mute History",
"info.no-mute-history": "This user has never been muted",
"info.muted-until": "Muted until %1",
"info.muted-expiry": "Expiry",
"info.muted-no-reason": "No reason given.",
"info.username-history": "История изменения имён",
"info.email-history": "История изменения электронной почты",
"info.moderation-note": "Примечание модератора",

View File

@@ -19,7 +19,7 @@
"manage/groups": "Nhóm",
"manage/ip-blacklist": "Danh sách đen IP",
"manage/uploads": "Tải lên",
"manage/digest": "Bản tóm tắt",
"manage/digest": "Bản Tóm Tắt",
"section-settings": "Cài đặt",
"settings/general": "Chung",

View File

@@ -3,7 +3,7 @@
"subcategories": "Danh mục phụ",
"uncategorized": "Chưa có danh mục",
"uncategorized.description": "Các chủ đề không phù hợp với bất kỳ danh mục hiện có nào",
"handle.description": "Có thể theo dõi danh mục này từ mạng xã hội mở thông qua xử lý %1",
"handle.description": "Danh mục này có thể được theo sau từ web xã hội mở thông qua xử lý %1",
"new-topic-button": "Chủ Đề Mới",
"guest-login-post": "Đăng nhập để đăng bài",
"no-topics": "<strong>Không có chủ đề nào trong danh mục này.</strong><br />Sao bạn không thử đăng?",
@@ -16,14 +16,14 @@
"tracking": "Theo dõi",
"not-watching": "Chưa Xem",
"ignoring": "Bỏ qua",
"watching.description": "Thông báo tôi chủ đề mới.<br/>Hiển thị chủ đề trong chưa đọc và gần đây",
"tracking.description": "Hiển thị các chủ đề trong chưa đọc & gần đây",
"not-watching.description": "Không hiển thị các chủ đề trong chưa đọc, hiển thị trong gần đây",
"ignoring.description": "Không hiển thị các chủ đề trong chưa đọc & gần đây",
"watching.message": "Bạn hiện đang xem các cập nhật từ danh mục này và tất cả các danh mục phụ",
"tracking.message": "Bạn hiện đang theo dõi các cập nhật từ danh mục này và tất cả các danh mục phụ",
"notwatching.message": "Bạn không xem các cập nhật từ danh mục này và tất cả các danh mục phụ",
"ignoring.message": "Bạn hiện đang bỏ qua các cập nhật từ danh mục này và tất cả các danh mục phụ",
"watching.description": "Thông báo tôi chủ đề mới.<br/>Hiển thị chủ đề chưa đọc và gần đây",
"tracking.description": "Hiển thị chủ đề chưa đọc gần đây",
"not-watching.description": "Không hiển thị chủ đề trong chưa đọc, hiển thị gần đây",
"ignoring.description": "Không hiển thị chủ đề chưa đọc gần đây",
"watching.message": "Bây giờ bạn đang xem cập nhật từ danh mục này và tất cả các danh mục phụ",
"tracking.message": "Bạn hiện đang theo dõi thông tin cập nhật từ danh mục này và tất cả các danh mục phụ",
"notwatching.message": "Bạn không xem cập nhật từ danh mục này và tất cả các danh mục phụ",
"ignoring.message": "Bây giờ bạn đang bỏ qua các cập nhật từ danh mục này và tất cả các danh mục phụ",
"watched-categories": "Danh mục đã xem",
"x-more-categories": "%1 danh mục khác"
"x-more-categories": "%1 chuyên mục khác"
}

View File

@@ -4,7 +4,7 @@
"week": "Tuần",
"month": "Tháng",
"year": "Năm",
"alltime": "Tất Cả Thời Gian",
"alltime": "Mọi Lúc",
"no-recent-topics": "Không có chủ đề gần đây.",
"no-popular-topics": "Không có chủ đề phổ biến.",
"load-new-posts": "Tải bài đăng mới",

View File

@@ -10,7 +10,7 @@
"handle": "版块句柄",
"handle.help": "您的版块句柄在其他网络中用作该版块的代表,类似于用户名。版块句柄不得与现有的用户名或用户组相匹配。",
"description": "版块描述",
"federatedDescription": "“联邦”说明",
"federatedDescription": "“联邦”说明",
"federatedDescription.help": "当其他网站/应用程序查询时,该文本将附加到版块描述中。",
"federatedDescription.default": "这是一个包含专题讨论的论坛版块。您可以通过提及该版块开始新的讨论。",
"bg-color": "背景颜色",

View File

@@ -20,7 +20,7 @@
"server-filtering": "过滤",
"count": "该 NodeBB 目前可检测到 <strong>%1</strong> 台服务器",
"server.filter-help": "指定您希望禁止与 NodeBB 联邦化的服务器。或者,您也可以选择性地 <em>允许</em> 与特定服务器联邦。两者只能选其一。",
"server.filter-help": "指定您希望禁止与 NodeBB 联的服务器。或者,您也可以选择性地 <em>允许</em> 与特定服务器联邦。两者只能选其一。",
"server.filter-help-hostname": "请在下面输入实例主机名(如 <code>example.org</code> ),中间用换行符隔开。",
"server.filter-allow-list": "将其用作允许列表"
}

View File

@@ -7,7 +7,7 @@
"help.title": "这是什么页面?",
"help.intro": "欢迎来到联邦宇宙的一角",
"help.fediverse": "联邦宇宙是一个由相互连接的应用程序和网站组成的网络,这些应用程序和网站可以相互对话,其用户也可以相互看到对方。本论坛是联盟式的,可以与该社交网络(或 “联邦宇宙”)互动。本页面是您在联邦宇宙中的一角。它仅由 <strong>你</strong> 关注的用户创建和分享的主题组成。",
"help.build": "起初,这里可能没有很多主题;这很正常。随着时间的推移,当您开始关注其他用户时,您会在这里看到更多的内容。",
"help.build": "开始时,这里可能没有很多主题;这很正常。随着时间的推移,当您开始关注其他用户时,您会在这里看到更多的内容。",
"help.federating": "同样,如果本论坛以外的用户开始关注 <em>你</em> ,那么您的帖子也会开始出现在这些应用程序和网站上。",
"help.next-generation": "这是新一代的社交媒体,从今天开始,贡献力量吧!",

View File

@@ -24,9 +24,6 @@ get:
error:
type: string
description: Translation key for client-side localisation
alreadyValidated:
type: boolean
description: set to true if the email was already validated
required:
- title
- $ref: ../../components/schemas/CommonProps.yaml#/CommonProps

View File

@@ -4,6 +4,7 @@ require('../app');
// scripts-admin.js is generated during build, it contains javascript files
// from plugins that add files to "acpScripts" block in plugin.json
// eslint-disable-next-line
require('../../scripts-admin');
app.onDomReady();

View File

@@ -23,6 +23,7 @@ Chart.register(
);
// eslint-disable-next-line import/prefer-default-export
export function init() {
setupCharts();

View File

@@ -64,6 +64,7 @@ $(window).on('action:ajaxify.start', function () {
usedTopicColors.length = 0;
});
// eslint-disable-next-line import/prefer-default-export
export function init() {
app.enterRoom('admin');

View File

@@ -21,6 +21,7 @@ Chart.register(
Filler
);
// eslint-disable-next-line import/prefer-default-export
export function init() {
categorySelector.init($('[component="category-selector"]'), {
onSelect: function (selectedCategory) {

View File

@@ -3,6 +3,7 @@ import { error } from '../../modules/alerts';
import * as categorySelector from '../../modules/categorySelector';
// eslint-disable-next-line import/prefer-default-export
export function init() {
categorySelector.init($('[component="category-selector"]'), {
onSelect: function (selectedCategory) {

View File

@@ -30,6 +30,7 @@ Chart.register(
let _current = null;
let isMobile = false;
// eslint-disable-next-line import/prefer-default-export
export function init({ set, dataset }) {
const canvas = document.getElementById('analytics-traffic');
const canvasCtx = canvas.getContext('2d');

View File

@@ -38,13 +38,12 @@ ajaxify.widgets = { render: render };
if (!pathname) {
({ pathname } = urlObj);
}
} catch (err) {
console.error(err);
} catch (e) {
return false;
}
const internalLink = utils.isInternalURI(urlObj, window.location, config.relative_path);
// eslint-disable-next-line no-script-url
const hrefEmpty = href => href === undefined || href === '' || href === 'javascript:;';
if (item instanceof Element) {
@@ -56,6 +55,7 @@ ajaxify.widgets = { render: render };
return null;
}
// eslint-disable-next-line no-script-url
if (hrefEmpty(urlObj.href) || urlObj.protocol === 'javascript:' || pathname === '#' || pathname === '') {
return null;
}
@@ -514,6 +514,7 @@ ajaxify.widgets = { render: render };
cache: false,
dataType: 'text',
success: function (script) {
// eslint-disable-next-line no-new-func
const renderFunction = new Function('module', script);
const moduleObj = { exports: {} };
renderFunction(moduleObj);

View File

@@ -4,6 +4,7 @@ require('./app');
// scripts-client.js is generated during build, it contains javascript files
// from plugins that add files to "scripts" block in plugin.json
// eslint-disable-next-line
require('../scripts-client');
app.onDomReady();

View File

@@ -26,8 +26,7 @@ define('forum/account/sessions', ['forum/account/header', 'components', 'api', '
window.location.href = config.relative_path + '/login?error=' + errorObj.title;
}
alerts.error(errorObj.title);
} catch (err) {
console.error(err);
} catch (e) {
alerts.error('[[error:invalid-data]]');
}
});

View File

@@ -70,7 +70,7 @@ define('forum/category', [
const $this = $(this);
const state = $this.attr('data-state');
api.put(`/categories/${encodeURIComponent(cid)}/watch`, { state }, (err) => {
api.put(`/categories/${cid}/watch`, { state }, (err) => {
if (err) {
return alerts.error(err);
}
@@ -118,7 +118,7 @@ define('forum/category', [
};
Category.toBottom = async () => {
const { count } = await api.get(`/categories/${encodeURIComponent(ajaxify.data.category.cid)}/count`);
const { count } = await api.get(`/categories/${ajaxify.data.category.cid}/count`);
navigator.scrollBottom(count - 1);
};
@@ -127,7 +127,7 @@ define('forum/category', [
hooks.fire('action:topics.loading');
const params = utils.params();
infinitescroll.loadMore(`/categories/${encodeURIComponent(ajaxify.data.cid)}/topics`, {
infinitescroll.loadMore(`/categories/${ajaxify.data.cid}/topics`, {
after: after,
direction: direction,
query: params,

View File

@@ -132,7 +132,6 @@ define('forum/chats/manage', [
try {
data = await api.get(`/chats/${roomId}/users`, {});
} catch (err) {
console.error(err);
listEl.find('li').text(await translator.translate('[[error:invalid-data]]'));
}
}

View File

@@ -1,3 +1,5 @@
/* eslint-disable no-redeclare */
'use strict';
const $ = require('jquery');

View File

@@ -1,3 +1,5 @@
/* eslint-disable import/first */
export * from 'ace-builds';
// only import the modes and theme we use
@@ -7,7 +9,8 @@ import 'ace-builds/src-noconflict/mode-html';
import 'ace-builds/src-noconflict/ext-searchbox';
import 'ace-builds/src-noconflict/theme-twilight';
/* eslint-disable import/no-webpack-loader-syntax */
/* eslint-disable import/no-unresolved */
import htmlWorkerUrl from 'file-loader!ace-builds/src-noconflict/worker-html';
import javascriptWorkerUrl from 'file-loader!ace-builds/src-noconflict/worker-javascript';
import cssWorkerUrl from 'file-loader!ace-builds/src-noconflict/worker-css';

View File

@@ -1,3 +1,5 @@
/* eslint-disable import/no-unresolved */
'use strict';
import { fire as fireHook } from 'hooks';

View File

@@ -222,7 +222,6 @@ define('iconSelect', ['benchpress', 'bootbox'], function (Benchpress, bootbox) {
{ id: 'android', label: 'Android (brands)', style: 'brands' },
{ id: 'address-book', label: 'Address Book (solid)', style: 'solid' },
];
iconSelect.init = function (el, onModified) {
onModified = onModified || function () { };
let selected = cleanFAClass(el[0].classList);
@@ -231,7 +230,6 @@ define('iconSelect', ['benchpress', 'bootbox'], function (Benchpress, bootbox) {
try {
$(`#icons .nbb-fa-icons ${selected.styles.length ? '.' + selected.styles.join('.') : ''}.${selected.icon}`).addClass('selected');
} catch (err) {
console.error(err);
selected = {
icon: '',
style: '',

View File

@@ -270,8 +270,7 @@ define('search', [
let term = data.term.replace(/^[ ?#]*/, '');
try {
term = encodeURIComponent(term);
} catch (err) {
console.error(err);
} catch (e) {
return alerts.error('[[error:invalid-search-term]]');
}
@@ -292,8 +291,7 @@ define('search', [
Search.getSearchPreferences = function () {
try {
return JSON.parse(storage.getItem('search-preferences') || '{}');
} catch (err) {
console.error(err);
} catch (e) {
return {};
}
};

View File

@@ -2,10 +2,11 @@
define('settings', ['hooks', 'alerts'], function (hooks, alerts) {
// eslint-disable-next-line prefer-const
let Settings;
let onReady = [];
let waitingJobs = 0;
// eslint-disable-next-line prefer-const
let helper;
/**
@@ -29,6 +30,7 @@ define('settings', ['hooks', 'alerts'], function (hooks, alerts) {
return null;
}
// eslint-disable-next-line prefer-const
helper = {
/**
@returns Object A deep clone of the given object.
@@ -324,13 +326,12 @@ define('settings', ['hooks', 'alerts'], function (hooks, alerts) {
use: function (settings) {
try {
settings._ = JSON.parse(settings._);
} catch (err) {
console.error(err);
}
} catch (_error) {}
Settings.cfg = settings;
},
};
// eslint-disable-next-line prefer-const
Settings = {
helper: helper,
plugins: {},
@@ -473,8 +474,8 @@ define('settings', ['hooks', 'alerts'], function (hooks, alerts) {
if (key && values.hasOwnProperty(key)) {
try {
values[key] = JSON.parse(values[key]);
} catch (err) {
console.error(err);
} catch (e) {
// Leave the value as is
}
}
});

View File

@@ -105,8 +105,7 @@ define('settings/array', function () {
separator = (function () {
try {
return $(separator);
} catch (err) {
console.error(err);
} catch (_error) {
return $(document.createTextNode(separator));
}
}());

View File

@@ -68,8 +68,7 @@ define('settings/object', function () {
separator = (function () {
try {
return $(separator);
} catch (err) {
console.error(err);
} catch (_error) {
return $(document.createTextNode(separator));
}
}());

View File

@@ -107,8 +107,7 @@ define('uploader', ['jquery-form'], function () {
if (typeof response === 'string') {
try {
return JSON.parse(response);
} catch (err) {
console.error(err);
} catch (e) {
return { error: '[[error:parse-error]]' };
}
}

View File

@@ -51,9 +51,7 @@ define('userFilter', ['api', 'hooks', 'slugify', 'benchpress'], function (api, h
try {
const userData = await api.get(`/api/user/${slugify(query)}`);
result.users.push(userData);
} catch (err) {
console.error(err);
}
} catch (err) {}
}
}
if (!result.users.length) {

View File

@@ -1,8 +1,10 @@
'use strict';
// eslint-disable-next-line no-redeclare
const io = require('socket.io-client');
// eslint-disable-next-line no-redeclare
const $ = require('jquery');
// eslint-disable-next-line import/no-unresolved
const { alert } = require('alerts');
app = window.app || {};

View File

@@ -272,6 +272,7 @@ const HTMLEntities = Object.freeze({
'diams;': 9830,
});
/* eslint-disable no-redeclare */
const utils = {
// https://github.com/substack/node-ent/blob/master/index.js
decodeHTMLEntities: function (html) {
@@ -466,8 +467,7 @@ const utils = {
try {
return new Date(parseInt(timestamp, 10)).toISOString();
} catch (err) {
console.error(err);
} catch (e) {
return timestamp;
}
},
@@ -631,8 +631,7 @@ const utils = {
try {
str = JSON.parse(str);
// eslint-disable-next-line no-unused-vars
} catch (err) { /* empty */ }
} catch (e) {}
return str;
},

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-redeclare */
'use strict';

View File

@@ -37,7 +37,7 @@
if ($el.css('background-size') == "cover") {
var elementWidth = $el.innerWidth(),
elementHeight = $el.innerHeight(),
elementAspectRatio = elementWidth / elementHeight,
elementAspectRatio = elementWidth / elementHeight;
imageAspectRatio = image.width / image.height,
scale = 1;
@@ -182,17 +182,18 @@
}
$.fn.backgroundDraggable = function(options) {
var options = options;
var args = Array.prototype.slice.call(arguments, 1);
return this.each(function() {
var $this = $(this);
var plugin;
if (typeof options == 'undefined' || typeof options == 'object') {
options = $.extend({}, $.fn.backgroundDraggable.defaults, options);
plugin = new Plugin(this, options);
var plugin = new Plugin(this, options);
$this.data('dbg', plugin);
} else if (typeof options == 'string' && $this.data('dbg')) {
plugin = $this.data('dbg');
var plugin = $this.data('dbg');
Plugin.prototype[options].apply(plugin, args);
}
});
@@ -201,6 +202,6 @@
$.fn.backgroundDraggable.defaults = {
bound: true,
axis: undefined,
units: 'pixels',
units: 'pixels'
};
}(jQuery));

View File

@@ -15,6 +15,7 @@
],
"rangeStrategy": "pin",
"matchPackageNames": [
"!colors"
]
},
{

View File

@@ -7,9 +7,7 @@ const _ = require('lodash');
const db = require('../database');
const meta = require('../meta');
const batch = require('../batch');
const categories = require('../categories');
const user = require('../user');
const topics = require('../topics');
const utils = require('../utils');
const TTLCache = require('../cache/ttl');
@@ -22,12 +20,15 @@ const activitypub = module.parent.exports;
const Actors = module.exports;
Actors.qualify = async (ids, options = {}) => {
Actors.assert = async (ids, options = {}) => {
/**
* Sanity-checks, cache handling, webfinger translations, so that only
* an array of actor uris are handled by assert/assertGroup.
*
* This method is only called by assert/assertGroup (at least in core.)
* Ensures that the passed in ids or webfinger handles are stored in database.
* Options:
* - update: boolean, forces re-fetch/process of the resolved id
* Return one of:
* - An array of newly processed ids
* - false: if input incorrect (or webfinger handle cannot resolve)
* - true: no new IDs processed; all passed-in IDs present.
*/
// Handle single values
@@ -46,6 +47,7 @@ Actors.qualify = async (ids, options = {}) => {
ids = ids.filter(id => !utils.isNumber(id));
// Translate webfinger handles to uris
const hostMap = new Map();
ids = (await Promise.all(ids.map(async (id) => {
const originalId = id;
if (activitypub.helpers.isWebfinger(id)) {
@@ -55,6 +57,7 @@ Actors.qualify = async (ids, options = {}) => {
}
({ actorUri: id } = await activitypub.helpers.query(id));
hostMap.set(id, host);
}
// ensure the final id is a valid URI
if (!id || !activitypub.helpers.isUri(id)) {
@@ -74,44 +77,18 @@ Actors.qualify = async (ids, options = {}) => {
ids = ids.filter(uri => uri !== 'loopback' && new URL(uri).host !== nconf.get('url_parsed').host);
}
// Separate those who need migration from user to category
const migrate = new Set();
if (options.qualifyGroup) {
const exists = await db.exists(ids.map(id => `userRemote:${id}`));
ids.forEach((id, idx) => {
if (exists[idx]) {
migrate.add(id);
}
});
}
// Only assert those who haven't been seen recently (configurable), unless update flag passed in (force refresh)
if (!options.update) {
const upperBound = Date.now() - (1000 * 60 * 60 * 24 * meta.config.activitypubUserPruneDays);
const lastCrawled = await db.sortedSetScores('usersRemote:lastCrawled', ids.map(id => ((typeof id === 'object' && id.hasOwnProperty('id')) ? id.id : id)));
ids = ids.filter((id, idx) => {
const timestamp = lastCrawled[idx];
return migrate.has(id) || !timestamp || timestamp < upperBound;
return !timestamp || timestamp < upperBound;
});
}
return ids;
};
Actors.assert = async (ids, options = {}) => {
/**
* Ensures that the passed in ids or webfinger handles are stored in database.
* Options:
* - update: boolean, forces re-fetch/process of the resolved id
* Return one of:
* - An array of newly processed ids
* - false: if input incorrect (or webfinger handle cannot resolve)
* - true: no new IDs processed; all passed-in IDs present.
*/
ids = await Actors.qualify(ids, options);
if (!ids || !ids.length) {
return ids;
if (!ids.length) {
return true;
}
activitypub.helpers.log(`[activitypub/actors] Asserting ${ids.length} actor(s)`);
@@ -121,7 +98,6 @@ Actors.assert = async (ids, options = {}) => {
const urlMap = new Map();
const followersUrlMap = new Map();
const pubKeysMap = new Map();
const categories = new Set();
let actors = await Promise.all(ids.map(async (id) => {
try {
activitypub.helpers.log(`[activitypub/actors] Processing ${id}`);
@@ -130,14 +106,8 @@ Actors.assert = async (ids, options = {}) => {
let typeOk = false;
if (Array.isArray(actor.type)) {
typeOk = actor.type.some(type => activitypub._constants.acceptableActorTypes.has(type));
if (!typeOk && actor.type.some(type => activitypub._constants.acceptableGroupTypes.has(type))) {
categories.add(actor.id);
}
} else {
typeOk = activitypub._constants.acceptableActorTypes.has(actor.type);
if (!typeOk && activitypub._constants.acceptableGroupTypes.has(actor.type)) {
categories.add(actor.id);
}
}
if (
@@ -179,11 +149,7 @@ Actors.assert = async (ids, options = {}) => {
if (e.code === 'ap_get_410') {
const exists = await user.exists(id);
if (exists) {
try {
await user.deleteAccount(id);
} catch (e) {
await activitypub.actors.remove(id);
}
await user.deleteAccount(id);
}
}
@@ -191,12 +157,9 @@ Actors.assert = async (ids, options = {}) => {
}
}));
actors = actors.filter(Boolean); // remove unresolvable actors
if (!actors.length && !categories.size) {
return [];
}
// Build userData object for storage
const profiles = (await activitypub.mocks.profile(actors)).filter(Boolean);
const profiles = (await activitypub.mocks.profile(actors, hostMap)).filter(Boolean);
const now = Date.now();
const bulkSet = profiles.reduce((memo, profile) => {
@@ -252,188 +215,10 @@ Actors.assert = async (ids, options = {}) => {
db.setObject('handle:uid', queries.handleAdd),
]);
// Handle any actors that should be asserted as a group instead
if (categories.size) {
const assertion = await Actors.assertGroup(Array.from(categories), options);
if (assertion === false) {
return false;
} else if (Array.isArray(assertion)) {
return [...actors, ...assertion];
}
// otherwise, assertGroup returned true and output can be safely ignored.
}
return actors;
};
Actors.assertGroup = async (ids, options = {}) => {
/**
* Ensures that the passed in ids or webfinger handles are stored in database.
* Options:
* - update: boolean, forces re-fetch/process of the resolved id
* Return one of:
* - An array of newly processed ids
* - false: if input incorrect (or webfinger handle cannot resolve)
* - true: no new IDs processed; all passed-in IDs present.
*/
ids = await Actors.qualify(ids, {
qualifyGroup: true,
...options,
});
if (!ids) {
return ids;
}
activitypub.helpers.log(`[activitypub/actors] Asserting ${ids.length} group(s)`);
// NOTE: MAKE SURE EVERY DB ADDITION HAS A CORRESPONDING REMOVAL IN ACTORS.REMOVEGROUP!
const urlMap = new Map();
const followersUrlMap = new Map();
const pubKeysMap = new Map();
let groups = await Promise.all(ids.map(async (id) => {
try {
activitypub.helpers.log(`[activitypub/actors] Processing group ${id}`);
const actor = (typeof id === 'object' && id.hasOwnProperty('id')) ? id : await activitypub.get('uid', 0, id, { cache: process.env.CI === 'true' });
const typeOk = Array.isArray(actor.type) ?
actor.type.some(type => activitypub._constants.acceptableGroupTypes.has(type)) :
activitypub._constants.acceptableGroupTypes.has(actor.type);
if (
!typeOk ||
!activitypub._constants.requiredActorProps.every(prop => actor.hasOwnProperty(prop))
) {
return null;
}
// Save url for backreference
const url = Array.isArray(actor.url) ? actor.url.shift() : actor.url;
if (url && url !== actor.id) {
urlMap.set(url, actor.id);
}
// Save followers url for backreference
if (actor.hasOwnProperty('followers') && activitypub.helpers.isUri(actor.followers)) {
followersUrlMap.set(actor.followers, actor.id);
}
// Public keys
pubKeysMap.set(actor.id, actor.publicKey);
return actor;
} catch (e) {
if (e.code === 'ap_get_410') {
const exists = await categories.exists(id);
if (exists) {
await categories.purge(id, 0);
}
}
return null;
}
}));
groups = groups.filter(Boolean); // remove unresolvable actors
// Build userData object for storage
const categoryObjs = (await activitypub.mocks.category(groups)).filter(Boolean);
const now = Date.now();
const bulkSet = categoryObjs.reduce((memo, category) => {
const key = `categoryRemote:${category.cid}`;
memo.push([key, category], [`${key}:keys`, pubKeysMap.get(category.cid)]);
return memo;
}, []);
if (urlMap.size) {
bulkSet.push(['remoteUrl:cid', Object.fromEntries(urlMap)]);
}
if (followersUrlMap.size) {
bulkSet.push(['followersUrl:cid', Object.fromEntries(followersUrlMap)]);
}
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', categoryObjs.map(p => p.cid));
const cidsForCurrent = categoryObjs.map((p, idx) => (exists[idx] ? p.cid : 0));
const current = await categories.getCategoriesFields(cidsForCurrent, ['slug']);
const queries = categoryObjs.reduce((memo, profile, idx) => {
const { slug, name } = current[idx];
if (options.update || slug !== profile.slug) {
if (cidsForCurrent[idx] !== 0 && slug) {
// memo.searchRemove.push(['ap.preferredUsername:sorted', `${slug.toLowerCase()}:${profile.uid}`]);
memo.handleRemove.push(slug.toLowerCase());
}
memo.searchAdd.push(['categories:name', 0, `${profile.slug.slice(0, 200).toLowerCase()}:${profile.cid}`]);
memo.handleAdd[profile.slug.toLowerCase()] = profile.cid;
}
if (options.update || (profile.name && name !== profile.name)) {
if (name && cidsForCurrent[idx] !== 0) {
memo.searchRemove.push(['categories:name', `${name.toLowerCase()}:${profile.cid}`]);
}
memo.searchAdd.push(['categories:name', 0, `${profile.name.toLowerCase()}:${profile.cid}`]);
}
return memo;
}, { searchRemove: [], searchAdd: [], handleRemove: [], handleAdd: {} });
// Removals
await Promise.all([
db.sortedSetRemoveBulk(queries.searchRemove),
db.deleteObjectFields('handle:cid', queries.handleRemove),
]);
await Promise.all([
db.setObjectBulk(bulkSet),
db.sortedSetAdd('usersRemote:lastCrawled', groups.map(() => now), groups.map(p => p.id)),
db.sortedSetAddBulk(queries.searchAdd),
db.setObject('handle:cid', queries.handleAdd),
_migratePersonToGroup(categoryObjs),
]);
return categoryObjs;
};
async function _migratePersonToGroup(categoryObjs) {
// 4.0.0-4.1.x asserted as:Group as users. This moves relevant stuff over and deletes the now-duplicate user.
let ids = categoryObjs.map(category => category.cid);
const slugs = categoryObjs.map(category => category.slug);
const isUser = await db.isObjectFields('handle:uid', slugs);
ids = ids.filter((id, idx) => isUser[idx]);
if (!ids.length) {
return;
}
await Promise.all(ids.map(async (id) => {
const shares = await db.getSortedSetMembers(`uid:${id}:shares`);
let cids = await topics.getTopicsFields(shares, ['cid']);
cids = cids.map(o => o.cid);
await Promise.all(shares.map(async (share, idx) => {
const cid = cids[idx];
if (cid === -1) {
await topics.tools.move(share, {
cid: id,
uid: 'system',
});
}
}));
const followers = await db.getSortedSetMembersWithScores(`followersRemote:${id}`);
await db.sortedSetAdd(
`cid:${id}:uid:watch:state`,
followers.map(() => categories.watchStates.tracking),
followers.map(({ value }) => value),
);
await user.deleteAccount(id);
}));
await categories.onTopicsMoved(ids);
}
Actors.getLocalFollowers = async (id) => {
// Returns local uids and cids that follow a remote actor (by id)
const response = {
uids: new Set(),
cids: new Set(),
@@ -443,27 +228,15 @@ Actors.getLocalFollowers = async (id) => {
return response;
}
const [isUser, isCategory] = await Promise.all([
user.exists(id),
categories.exists(id),
]);
const members = await db.getSortedSetMembers(`followersRemote:${id}`);
if (isUser) {
const members = await db.getSortedSetMembers(`followersRemote:${id}`);
members.forEach((id) => {
if (utils.isNumber(id)) {
response.uids.add(parseInt(id, 10));
} else if (id.startsWith('cid|') && utils.isNumber(id.slice(4))) {
response.cids.add(parseInt(id.slice(4), 10));
}
});
} else if (isCategory) {
const members = await db.getSortedSetRangeByScore(`cid:${id}:uid:watch:state`, 0, -1, categories.watchStates.tracking, categories.watchStates.watching);
members.forEach((uid) => {
response.uids.add(uid);
});
}
members.forEach((id) => {
if (utils.isNumber(id)) {
response.uids.add(parseInt(id, 10));
} else if (id.startsWith('cid|') && utils.isNumber(id.slice(4))) {
response.cids.add(parseInt(id.slice(4), 10));
}
});
return response;
};
@@ -533,108 +306,38 @@ Actors.remove = async (id) => {
]);
};
Actors.removeGroup = async (id) => {
/**
* Remove ActivityPub related metadata pertaining to a remote id
*
* Note: don't call this directly! It is called as part of categories.purge
*/
const exists = await db.isSortedSetMember('usersRemote:lastCrawled', id);
if (!exists) {
return false;
}
let { slug, name, url, followersUrl } = await categories.getCategoryFields(id, ['slug', 'name', 'url', 'followersUrl']);
slug = slug.toLowerCase();
const bulkRemove = [
['categories:name', `${slug}:${id}`],
];
if (name) {
bulkRemove.push(['categories:name', `${name.toLowerCase()}:${id}`]);
}
await Promise.all([
db.sortedSetRemoveBulk(bulkRemove),
db.deleteObjectField('handle:cid', slug),
db.deleteObjectField('followersUrl:cid', followersUrl),
db.deleteObjectField('remoteUrl:cid', url),
db.delete(`categoryRemote:${id}:keys`),
]);
await Promise.all([
db.delete(`categoryRemote:${id}`),
db.sortedSetRemove('usersRemote:lastCrawled', id),
]);
};
Actors.prune = async () => {
/**
* Clear out remote user accounts that do not have content on the forum anywhere
*/
activitypub.helpers.log('[actors/prune] Started scheduled pruning of remote user accounts and categories');
winston.info('[actors/prune] Started scheduled pruning of remote user accounts');
const days = parseInt(meta.config.activitypubUserPruneDays, 10);
const timestamp = Date.now() - (1000 * 60 * 60 * 24 * days);
const ids = await db.getSortedSetRangeByScore('usersRemote:lastCrawled', 0, 500, '-inf', timestamp);
if (!ids.length) {
activitypub.helpers.log('[actors/prune] No remote actors to prune, all done.');
return {
counts: {
deleted: 0,
missing: 0,
preserved: 0,
},
preserved: new Set(),
};
const uids = await db.getSortedSetRangeByScore('usersRemote:lastCrawled', 0, 500, '-inf', timestamp);
if (!uids.length) {
winston.info('[actors/prune] No remote users to prune, all done.');
return;
}
activitypub.helpers.log(`[actors/prune] Found ${ids.length} remote actors last crawled more than ${days} days ago`);
winston.info(`[actors/prune] Found ${uids.length} remote users last crawled more than ${days} days ago`);
let deletionCount = 0;
let deletionCountNonExisting = 0;
let notDeletedDueToLocalContent = 0;
const preservedIds = [];
await batch.processArray(ids, async (ids) => {
const exists = await Promise.all([
db.exists(ids.map(id => `userRemote:${id}`)),
db.exists(ids.map(id => `categoryRemote:${id}`)),
]);
const notDeletedUids = [];
await batch.processArray(uids, async (uids) => {
const exists = await db.exists(uids.map(uid => `userRemote:${uid}`));
let uids = new Set();
let cids = new Set();
const missing = new Set();
ids.forEach((id, idx) => {
switch (true) {
case exists[0][idx]: {
uids.add(id);
break;
}
const uidsThatExist = uids.filter((uid, idx) => exists[idx]);
const uidsThatDontExist = uids.filter((uid, idx) => !exists[idx]);
case exists[1][idx]: {
cids.add(id);
break;
}
default: {
missing.add(id);
break;
}
}
});
uids = Array.from(uids);
cids = Array.from(cids);
// const uidsThatExist = ids.filter((uid, idx) => exists[idx]);
// const uidsThatDontExist = ids.filter((uid, idx) => !exists[idx]);
// Remote users
const [postCounts, roomCounts, followCounts] = await Promise.all([
db.sortedSetsCard(uids.map(uid => `uid:${uid}:posts`)),
db.sortedSetsCard(uids.map(uid => `uid:${uid}:chat:rooms`)),
Actors.getLocalFollowCounts(uids),
db.sortedSetsCard(uidsThatExist.map(uid => `uid:${uid}:posts`)),
db.sortedSetsCard(uidsThatExist.map(uid => `uid:${uid}:chat:rooms`)),
Actors.getLocalFollowCounts(uidsThatExist),
]);
await Promise.all(uids.map(async (uid, idx) => {
await Promise.all(uidsThatExist.map(async (uid, idx) => {
const { followers, following } = followCounts[idx];
const postCount = postCounts[idx];
const roomCount = roomCounts[idx];
@@ -647,46 +350,20 @@ Actors.prune = async () => {
}
} else {
notDeletedDueToLocalContent += 1;
preservedIds.push(uid);
notDeletedUids.push(uid);
}
}));
// Remote categories
let counts = await categories.getCategoriesFields(cids, ['topic_count']);
counts = counts.map(count => count.topic_count);
await Promise.all(cids.map(async (cid, idx) => {
const topicCount = counts[idx];
if (topicCount === 0) {
try {
await categories.purge(cid, 0);
deletionCount += 1;
} catch (err) {
winston.error(err.stack);
}
} else {
notDeletedDueToLocalContent += 1;
preservedIds.push(cid);
}
}));
deletionCountNonExisting += missing.size;
await db.sortedSetRemove('usersRemote:lastCrawled', Array.from(missing));
deletionCountNonExisting += uidsThatDontExist.length;
await db.sortedSetRemove('usersRemote:lastCrawled', uidsThatDontExist);
// update timestamp in usersRemote:lastCrawled so we don't try to delete users
// with content over and over
const now = Date.now();
await db.sortedSetAdd('usersRemote:lastCrawled', preservedIds.map(() => now), preservedIds);
await db.sortedSetAdd('usersRemote:lastCrawled', notDeletedUids.map(() => now), notDeletedUids);
}, {
batch: 50,
interval: 1000,
});
activitypub.helpers.log(`[actors/prune] ${deletionCount} remote users pruned. ${deletionCountNonExisting} did not exist. ${notDeletedDueToLocalContent} not deleted due to local content`);
return {
counts: {
deleted: deletionCount,
missing: deletionCountNonExisting,
preserved: notDeletedDueToLocalContent,
},
preserved: new Set(preservedIds),
};
winston.info(`[actors/prune] ${deletionCount} remote users pruned. ${deletionCountNonExisting} does not exist. ${notDeletedDueToLocalContent} not deleted due to local content`);
};

View File

@@ -50,7 +50,7 @@ inbox.create = async (req) => {
const asserted = await activitypub.notes.assert(0, object, { cid });
if (asserted) {
activitypub.feps.announce(object.id, req.body);
// api.activitypub.add(req, { pid: object.id });
api.activitypub.add(req, { pid: object.id });
}
};

View File

@@ -8,7 +8,6 @@ const { CronJob } = require('cron');
const request = require('../request');
const db = require('../database');
const meta = require('../meta');
const categories = require('../categories');
const posts = require('../posts');
const messaging = require('../messaging');
const user = require('../user');
@@ -40,8 +39,7 @@ ActivityPub._constants = Object.freeze({
acceptedPostTypes: [
'Note', 'Page', 'Article', 'Question', 'Video',
],
acceptableActorTypes: new Set(['Application', 'Organization', 'Person', 'Service']),
acceptableGroupTypes: new Set(['Group']),
acceptableActorTypes: new Set(['Application', 'Group', 'Organization', 'Person', 'Service']),
requiredActorProps: ['inbox', 'outbox'],
acceptedProtocols: ['https', ...(process.env.CI === 'true' ? ['http'] : [])],
acceptable: {
@@ -115,28 +113,11 @@ ActivityPub.resolveInboxes = async (ids) => {
}
await ActivityPub.actors.assert(ids);
// Remove non-asserted targets
const exists = await db.isSortedSetMembers('usersRemote:lastCrawled', ids);
ids = ids.filter((_, idx) => exists[idx]);
await batch.processArray(ids, async (currentIds) => {
const isCategory = await db.exists(currentIds.map(id => `categoryRemote:${id}`));
const [cids, uids] = currentIds.reduce(([cids, uids], id, idx) => {
const array = isCategory[idx] ? cids : uids;
array.push(id);
return [cids, uids];
}, [[], []]);
const categoryData = await categories.getCategoriesFields(cids, ['inbox', 'sharedInbox']);
const userData = await user.getUsersFields(uids, ['inbox', 'sharedInbox']);
currentIds.forEach((id) => {
if (cids.includes(id)) {
const data = categoryData[cids.indexOf(id)];
inboxes.add(data.sharedInbox || data.inbox);
} else if (uids.includes(id)) {
const data = userData[uids.indexOf(id)];
inboxes.add(data.sharedInbox || data.inbox);
const usersData = await user.getUsersFields(currentIds, ['inbox', 'sharedInbox']);
usersData.forEach((u) => {
if (u && (u.sharedInbox || u.inbox)) {
inboxes.add(u.sharedInbox || u.inbox);
}
});
}, {
@@ -414,8 +395,7 @@ ActivityPub.send = async (type, id, targets, payload) => {
...payload,
};
// Runs in background... potentially a better queue is required... later.
batch.processArray(
await batch.processArray(
inboxes,
async inboxBatch => Promise.all(inboxBatch.map(async uri => sendMessage(uri, id, type, payload))),
{

View File

@@ -129,7 +129,7 @@ Mocks._normalize = async (object) => {
};
};
Mocks.profile = async (actors) => {
Mocks.profile = async (actors, hostMap) => {
// Should only ever be called by activitypub.actors.assert
const profiles = await Promise.all(actors.map(async (actor) => {
if (!actor) {
@@ -137,7 +137,7 @@ Mocks.profile = async (actors) => {
}
const uid = actor.id;
let hostname;
let hostname = hostMap.get(uid);
let {
url, preferredUsername, published, icon, image,
name, summary, followers, inbox, endpoints, tag,
@@ -145,10 +145,12 @@ Mocks.profile = async (actors) => {
preferredUsername = slugify(preferredUsername || name);
const { followers: followerCount, following: followingCount } = await activitypub.actors.getLocalFollowCounts(uid);
try {
({ hostname } = new URL(actor.id));
} catch (e) {
return null;
if (!hostname) { // if not available via webfinger, infer from id
try {
({ hostname } = new URL(actor.id));
} catch (e) {
return null;
}
}
let picture;
@@ -216,7 +218,7 @@ Mocks.profile = async (actors) => {
uploadedpicture: undefined,
'cover:url': !image || typeof image === 'string' ? image : image.url,
'cover:position': '50% 50%',
aboutme: posts.sanitize(summary),
aboutme: summary,
followerCount,
followingCount,
@@ -233,73 +235,6 @@ Mocks.profile = async (actors) => {
return profiles;
};
Mocks.category = async (actors) => {
const categories = await Promise.all(actors.map(async (actor) => {
if (!actor) {
return null;
}
const cid = actor.id;
let hostname;
let {
url, preferredUsername, /* icon, */ image,
name, summary, followers, inbox, endpoints, tag,
} = actor;
preferredUsername = slugify(preferredUsername || name);
// const { followers: followerCount, following: followingCount } = await activitypub.actors.getLocalFollowCounts(uid);
try {
({ hostname } = new URL(actor.id));
} catch (e) {
return null;
}
// No support for category avatars yet ;(
// let picture;
// if (icon) {
// picture = typeof icon === 'string' ? icon : icon.url;
// }
const iconBackgrounds = await user.getIconBackgrounds();
let bgColor = Array.prototype.reduce.call(preferredUsername, (cur, next) => cur + next.charCodeAt(), 0);
bgColor = iconBackgrounds[bgColor % iconBackgrounds.length];
// Replace emoji in summary
if (tag && Array.isArray(tag)) {
tag
.filter(tag => tag.type === 'Emoji' &&
isEmojiShortcode.test(tag.name) &&
tag.icon && tag.icon.mediaType && tag.icon.mediaType.startsWith('image/'))
.forEach((tag) => {
summary = summary.replace(new RegExp(tag.name, 'g'), `<img class="not-responsive emoji" src="${tag.icon.url}" title="${tag.name}" />`);
});
}
const payload = {
cid,
name,
handle: preferredUsername,
slug: `${preferredUsername}@${hostname}`,
description: summary,
descriptionParsed: posts.sanitize(summary),
icon: 'fa-comments',
color: '#fff',
bgColor,
backgroundImage: !image || typeof image === 'string' ? image : image.url,
// followerCount,
// followingCount,
url,
inbox,
sharedInbox: endpoints ? endpoints.sharedInbox : null,
followersUrl: followers,
};
return payload;
}));
return categories;
};
Mocks.post = async (objects) => {
let single = false;
if (!Array.isArray(objects)) {
@@ -557,6 +492,7 @@ Mocks.notes.public = async (post) => {
const published = post.timestampISO;
const updated = post.edited ? post.editedISO : null;
// todo: post visibility
const to = new Set([activitypub._constants.publicAddress]);
const cc = new Set([`${nconf.get('url')}/uid/${post.user.uid}/followers`]);
@@ -701,15 +637,13 @@ Mocks.notes.public = async (post) => {
* audience is exposed as part of 1b12 but is now ignored by Lemmy.
* Remove this and most references to audience in 2026.
*/
let audience = utils.isNumber(post.category.cid) ? // default
`${nconf.get('url')}/category/${post.category.cid}` : post.category.cid;
let audience = `${nconf.get('url')}/category/${post.category.cid}`; // default
if (inReplyTo) {
const chain = await activitypub.notes.getParentChain(post.uid, inReplyTo);
chain.forEach((post) => {
audience = post.audience || audience;
});
}
to.add(audience);
let object = {
'@context': 'https://www.w3.org/ns/activitystreams',

View File

@@ -79,7 +79,6 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
const hasTid = !!tid;
const cid = hasTid ? await topics.getTopicField(tid, 'cid') : options.cid || -1;
if (options.cid && cid === -1) {
// Move topic if currently uncategorized
await topics.tools.move(tid, { cid: options.cid, uid: 'system' });
@@ -98,24 +97,16 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
if (hasTid) {
mainPid = await topics.getTopicField(tid, 'mainPid');
} else {
// Check recipients/audience for category (local or remote)
// Check recipients/audience for local category
const set = activitypub.helpers.makeSet(_activitypub, ['to', 'cc', 'audience']);
await activitypub.actors.assert(Array.from(set));
// Local
const resolved = await Promise.all(Array.from(set).map(async id => await activitypub.helpers.resolveLocalId(id)));
const recipientCids = resolved
.filter(Boolean)
.filter(({ type }) => type === 'category')
.map(obj => obj.id);
// Remote
const assertedGroups = await db.exists(Array.from(set).map(id => `categoryRemote:${id}`));
const remoteCid = Array.from(set).filter((_, idx) => assertedGroups[idx]).shift();
if (remoteCid || recipientCids.length) {
if (recipientCids.length) {
// Overrides passed-in value, respect addressing from main post over booster
options.cid = remoteCid || recipientCids.shift();
options.cid = recipientCids.shift();
}
// mainPid ok to leave as-is
@@ -139,7 +130,7 @@ Notes.assert = async (uid, input, options = { skipChecks: false }) => {
options.skipChecks || options.cid ||
await assertRelation(chain[inputIndex !== -1 ? inputIndex : 0]);
const privilege = `topics:${tid ? 'reply' : 'create'}`;
const allowed = await privileges.categories.can(privilege, options.cid || cid, activitypub._constants.uid);
const allowed = await privileges.categories.can(privilege, cid, activitypub._constants.uid);
if (!hasRelation || !allowed) {
if (!hasRelation) {
activitypub.helpers.log(`[activitypub/notes.assert] Not asserting ${id} as it has no relation to existing tracked content.`);
@@ -463,12 +454,6 @@ Notes.syncUserInboxes = async function (tid, uid) {
uids.add(uid);
});
// Category followers
const categoryFollowers = await activitypub.actors.getLocalFollowers(cid);
categoryFollowers.uids.forEach((uid) => {
uids.add(uid);
});
const keys = Array.from(uids).map(uid => `uid:${uid}:inbox`);
const score = await db.sortedSetScore(`cid:${cid}:tids`, tid);

View File

@@ -37,22 +37,13 @@ function enabledCheck(next) {
activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) => {
// Privilege checks should be done upstream
const acceptedTypes = ['uid', 'cid'];
const assertion = await activitypub.actors.assert(actor);
if (!acceptedTypes.includes(type) || !assertion || (Array.isArray(assertion) && assertion.length)) {
if (!assertion || (Array.isArray(assertion) && assertion.length)) {
throw new Error('[[error:activitypub.invalid-id]]');
}
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
const [handle, isFollowing] = await Promise.all([
user.getUserField(actor, 'username'),
db.isSortedSetMember(type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`, actor),
]);
if (isFollowing) { // already following
return;
}
const handle = await user.getUserField(actor, 'username');
const timestamp = Date.now();
await db.sortedSetAdd(`followRequests:${type}.${id}`, timestamp, actor);
@@ -70,22 +61,13 @@ activitypubApi.follow = enabledCheck(async (caller, { type, id, actor } = {}) =>
// should be .undo.follow
activitypubApi.unfollow = enabledCheck(async (caller, { type, id, actor }) => {
const acceptedTypes = ['uid', 'cid'];
const assertion = await activitypub.actors.assert(actor);
if (!acceptedTypes.includes(type) || !assertion) {
if (!assertion) {
throw new Error('[[error:activitypub.invalid-id]]');
}
actor = actor.includes('@') ? await user.getUidByUserslug(actor) : actor;
const [handle, isFollowing] = await Promise.all([
user.getUserField(actor, 'username'),
db.isSortedSetMember(type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`, actor),
]);
if (!isFollowing) { // already not following
return;
}
const handle = await user.getUserField(actor, 'username');
const timestamps = await db.sortedSetsScore([
`followRequests:${type}.${id}`,
type === 'uid' ? `followingRemote:${id}` : `cid:${id}:following`,
@@ -147,7 +129,7 @@ activitypubApi.create.note = enabledCheck(async (caller, { pid, post }) => {
await Promise.all([
activitypub.send('uid', caller.uid, Array.from(targets), activity),
activitypub.feps.announce(pid, activity),
// utils.isNumber(post.cid) ? activitypubApi.add(caller, { pid }) : undefined,
activitypubApi.add(caller, { pid }),
]);
});
@@ -405,7 +387,6 @@ activitypubApi.flag = enabledCheck(async (caller, flag) => {
await db.sortedSetAdd(`flag:${flag.flagId}:remote`, Date.now(), caller.uid);
});
/*
activitypubApi.add = enabledCheck((async (_, { pid }) => {
let localId;
if (String(pid).startsWith(nconf.get('url'))) {
@@ -432,7 +413,7 @@ activitypubApi.add = enabledCheck((async (_, { pid }) => {
target: `${nconf.get('url')}/topic/${tid}`,
});
}));
*/
activitypubApi.undo.flag = enabledCheck(async (caller, flag) => {
if (!activitypub.helpers.isUri(flag.targetId)) {
return;

View File

@@ -7,7 +7,6 @@ const events = require('../events');
const user = require('../user');
const groups = require('../groups');
const privileges = require('../privileges');
const utils = require('../utils');
const activitypubApi = require('./activitypub');
@@ -158,9 +157,7 @@ categoriesAPI.getTopics = async (caller, data) => {
categoriesAPI.setWatchState = async (caller, { cid, state, uid }) => {
let targetUid = caller.uid;
let cids = Array.isArray(cid) ? cid : [cid];
cids = cids.map(cid => (utils.isNumber(cid) ? parseInt(cid, 10) : cid));
const cids = Array.isArray(cid) ? cid.map(cid => parseInt(cid, 10)) : [parseInt(cid, 10)];
if (uid) {
targetUid = uid;
}

View File

@@ -87,7 +87,9 @@ topicsAPI.create = async function (caller, data) {
socketHelpers.notifyNew(caller.uid, 'newTopic', { posts: [result.postData], topic: result.topicData });
if (!isScheduling) {
await activitypubApi.create.note(caller, { pid: result.postData.pid });
setTimeout(() => {
activitypubApi.create.note(caller, { pid: result.postData.pid });
}, 5000);
}
return result.topicData;
@@ -123,7 +125,7 @@ topicsAPI.reply = async function (caller, data) {
}
socketHelpers.notifyNew(caller.uid, 'newPost', result);
await activitypubApi.create.note(caller, { post: postData });
activitypubApi.create.note(caller, { post: postData });
return postData;
};

View File

@@ -36,8 +36,8 @@ module.exports = function (Categories) {
return [];
}
cids = cids.map(cid => (utils.isNumber(cid) ? parseInt(cid, 10) : cid));
const keys = cids.map(cid => (utils.isNumber(cid) ? `category:${cid}` : `categoryRemote:${cid}`));
cids = cids.map(cid => parseInt(cid, 10));
const keys = cids.map(cid => `category:${cid}`);
const categories = await db.getObjects(keys, fields);
// Handle cid -1
@@ -87,11 +87,11 @@ module.exports = function (Categories) {
};
Categories.setCategoryField = async function (cid, field, value) {
await db.setObjectField(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, field, value);
await db.setObjectField(`category:${cid}`, field, value);
};
Categories.incrementCategoryFieldBy = async function (cid, field, value) {
await db.incrObjectFieldBy(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, field, value);
await db.incrObjectFieldBy(`category:${cid}`, field, value);
};
};

View File

@@ -7,9 +7,7 @@ const plugins = require('../plugins');
const topics = require('../topics');
const groups = require('../groups');
const privileges = require('../privileges');
const activitypub = require('../activitypub');
const cache = require('../cache');
const utils = require('../utils');
module.exports = function (Categories) {
Categories.purge = async function (cid, uid) {
@@ -40,7 +38,6 @@ module.exports = function (Categories) {
await removeFromParent(cid);
await deleteTags(cid);
await activitypub.actors.removeGroup(cid);
await db.deleteAll([
`cid:${cid}:tids`,
`cid:${cid}:tids:pinned`,
@@ -54,7 +51,7 @@ module.exports = function (Categories) {
`cid:${cid}:uid:watch:state`,
`cid:${cid}:children`,
`cid:${cid}:tag:whitelist`,
`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`,
`category:${cid}`,
]);
const privilegeList = await privileges.categories.getPrivilegeList();
await groups.destroy(privilegeList.map(privilege => `cid:${cid}:privileges:${privilege}`));

View File

@@ -10,7 +10,6 @@ const plugins = require('../plugins');
const privileges = require('../privileges');
const cache = require('../cache');
const meta = require('../meta');
const utils = require('../utils');
const Categories = module.exports;
@@ -27,14 +26,9 @@ require('./search')(Categories);
Categories.icons = require('./icon');
Categories.exists = async function (cids) {
let keys;
if (Array.isArray(cids)) {
keys = cids.map(cid => (utils.isNumber(cid) ? `category:${cid}` : `categoryRemote:${cid}`));
} else {
keys = utils.isNumber(cids) ? `category:${cids}` : `categoryRemote:${cids}`;
}
return await db.exists(keys);
return await db.exists(
Array.isArray(cids) ? cids.map(cid => `category:${cid}`) : `category:${cids}`
);
};
Categories.existsByHandle = async function (handle) {

View File

@@ -3,9 +3,7 @@
const _ = require('lodash');
const privileges = require('../privileges');
const activitypub = require('../activitypub');
const plugins = require('../plugins');
const utils = require('../utils');
const db = require('../database');
module.exports = function (Categories) {
@@ -17,10 +15,6 @@ module.exports = function (Categories) {
const startTime = process.hrtime();
if (activitypub.helpers.isWebfinger(query)) {
await activitypub.actors.assertGroup([query]);
}
let cids = await findCids(query, data.hardCap);
const result = await plugins.hooks.fire('filter:categories.search', {
@@ -77,12 +71,7 @@ module.exports = function (Categories) {
match: `*${String(query).toLowerCase()}*`,
limit: hardCap || 500,
});
return data.map((data) => {
const split = data.split(':');
split.shift();
const cid = split.join(':');
return utils.isNumber(cid) ? parseInt(cid, 10) : cid;
});
return data.map(data => parseInt(data.split(':').pop(), 10));
}
async function getChildrenCids(cids, uid) {

View File

@@ -9,7 +9,6 @@ const user = require('../user');
const notifications = require('../notifications');
const translator = require('../translator');
const batch = require('../batch');
const utils = require('../utils');
module.exports = function (Categories) {
Categories.getCategoryTopics = async function (data) {
@@ -187,7 +186,7 @@ module.exports = function (Categories) {
}
const promises = [
db.sortedSetAdd(`cid:${cid}:pids`, postData.timestamp, postData.pid),
db.incrObjectField(`${utils.isNumber(cid) ? 'category' : 'categoryRemote'}:${cid}`, 'post_count'),
db.incrObjectField(`category:${cid}`, 'post_count'),
];
if (!pinned) {
promises.push(db.sortedSetIncrBy(`cid:${cid}:tids:posts`, 1, postData.tid));
@@ -255,29 +254,18 @@ module.exports = function (Categories) {
notifications.push(notification, followers);
};
Categories.sortTidsBySet = async (tids, sort) => {
let cids = await topics.getTopicsFields(tids, ['cid']);
cids = cids.map(({ cid }) => cid);
function getSet(cid, sort) {
sort = sort || meta.config.categoryTopicSort || 'recently_replied';
const sortToSet = {
recently_replied: `cid:${cid}:tids`,
recently_created: `cid:${cid}:tids:create`,
most_posts: `cid:${cid}:tids:posts`,
most_votes: `cid:${cid}:tids:votes`,
most_views: `cid:${cid}:tids:views`,
};
return sortToSet[sort];
}
const scores = await Promise.all(tids.map(async (tid, idx) => {
const cid = cids[idx];
const orderBy = getSet(cid, sort);
return await db.sortedSetScore(orderBy, tid);
}));
Categories.sortTidsBySet = async (tids, cid, sort) => {
sort = sort || meta.config.categoryTopicSort || 'recently_replied';
const sortToSet = {
recently_replied: `cid:${cid}:tids`,
recently_created: `cid:${cid}:tids:create`,
most_posts: `cid:${cid}:tids:posts`,
most_votes: `cid:${cid}:tids:votes`,
most_views: `cid:${cid}:tids:views`,
};
const orderBy = sortToSet[sort];
const scores = await db.sortedSetScores(orderBy, tids);
const sorted = tids
.map((tid, idx) => [tid, scores[idx]])
.sort(([, a], [, b]) => b - a)

View File

@@ -3,7 +3,6 @@
const db = require('../database');
const user = require('../user');
const activitypub = require('../activitypub');
const utils = require('../utils');
module.exports = function (Categories) {
Categories.watchStates = {
@@ -33,11 +32,7 @@ module.exports = function (Categories) {
user.getSettings(uid),
db.sortedSetsScore(keys, uid),
]);
const fallbacks = cids.map(cid => (utils.isNumber(cid) ?
Categories.watchStates[userSettings.categoryWatchState] : Categories.watchStates.notwatching));
return states.map((state, idx) => state || fallbacks[idx]);
return states.map(state => state || Categories.watchStates[userSettings.categoryWatchState]);
};
Categories.getIgnorers = async function (cid, start, stop) {

View File

@@ -1,15 +1,160 @@
'use strict';
// todo: replace with styleText from node:util in node 20+
// override commander help formatting functions
// to include color styling in the output
// so the CLI looks nice
const { Command } = require('commander');
const chalk = require('chalk');
// https://github.com/tj/commander.js/blob/master/examples/color-help.mjs
module.exports = {
styleTitle: str => chalk.bold(str),
styleCommandText: str => chalk.cyan(str),
styleCommandDescription: str => chalk.magenta(str),
styleDescriptionText: str => chalk.italic(str),
styleOptionText: str => chalk.green(str),
styleArgumentText: str => chalk.yellow(str),
styleSubcommandText: str => chalk.blue(str),
const colors = [
// depth = 0, top-level command
{ command: 'yellow', option: 'cyan', arg: 'magenta' },
// depth = 1, second-level commands
{ command: 'green', option: 'blue', arg: 'red' },
// depth = 2, third-level commands
{ command: 'yellow', option: 'cyan', arg: 'magenta' },
// depth = 3 fourth-level commands
{ command: 'green', option: 'blue', arg: 'red' },
];
function humanReadableArgName(arg) {
const nameOutput = arg.name() + (arg.variadic === true ? '...' : '');
return arg.required ? `<${nameOutput}>` : `[${nameOutput}]`;
}
function getControlCharacterSpaces(term) {
const matches = term.match(/.\[\d+m/g);
return matches ? matches.length * 5 : 0;
}
// get depth of command
// 0 = top, 1 = subcommand of top, etc
Command.prototype.depth = function () {
if (this._depth === undefined) {
let depth = 0;
let { parent } = this;
while (parent) { depth += 1; parent = parent.parent; }
this._depth = depth;
}
return this._depth;
};
module.exports = {
commandUsage(cmd) {
const depth = cmd.depth();
// Usage
let cmdName = cmd._name;
if (cmd._aliases[0]) {
cmdName = `${cmdName}|${cmd._aliases[0]}`;
}
let parentCmdNames = '';
let parentCmd = cmd.parent;
let parentDepth = depth - 1;
while (parentCmd) {
parentCmdNames = `${chalk[colors[parentDepth].command](parentCmd.name())} ${parentCmdNames}`;
parentCmd = parentCmd.parent;
parentDepth -= 1;
}
// from Command.prototype.usage()
const args = cmd._args.map(arg => chalk[colors[depth].arg](humanReadableArgName(arg)));
const cmdUsage = [].concat(
(cmd.options.length || cmd._hasHelpOption ? chalk[colors[depth].option]('[options]') : []),
(cmd.commands.length ? chalk[colors[depth + 1].command]('[command]') : []),
(cmd._args.length ? args : [])
).join(' ');
return `${parentCmdNames}${chalk[colors[depth].command](cmdName)} ${cmdUsage}`;
},
subcommandTerm(cmd) {
const depth = cmd.depth();
// Legacy. Ignores custom usage string, and nested commands.
const args = cmd._args.map(arg => humanReadableArgName(arg)).join(' ');
return chalk[colors[depth].command](cmd._name + (
cmd._aliases[0] ? `|${cmd._aliases[0]}` : ''
)) +
chalk[colors[depth].option](cmd.options.length ? ' [options]' : '') + // simplistic check for non-help option
chalk[colors[depth].arg](args ? ` ${args}` : '');
},
longestOptionTermLength(cmd, helper) {
return helper.visibleOptions(cmd).reduce((max, option) => Math.max(
max,
helper.optionTerm(option).length - getControlCharacterSpaces(helper.optionTerm(option))
), 0);
},
longestSubcommandTermLength(cmd, helper) {
return helper.visibleCommands(cmd).reduce((max, command) => Math.max(
max,
helper.subcommandTerm(command).length - getControlCharacterSpaces(helper.subcommandTerm(command))
), 0);
},
longestArgumentTermLength(cmd, helper) {
return helper.visibleArguments(cmd).reduce((max, argument) => Math.max(
max,
helper.argumentTerm(argument).length - getControlCharacterSpaces(helper.argumentTerm(argument))
), 0);
},
formatHelp(cmd, helper) {
const depth = cmd.depth();
const termWidth = helper.padWidth(cmd, helper);
const helpWidth = helper.helpWidth || 80;
const itemIndentWidth = 2;
const itemSeparatorWidth = 2; // between term and description
function formatItem(term, description) {
const padding = ' '.repeat((termWidth + itemSeparatorWidth) - (term.length - getControlCharacterSpaces(term)));
if (description) {
const fullText = `${term}${padding}${description}`;
return helper.wrap(fullText, helpWidth - itemIndentWidth, termWidth + itemSeparatorWidth);
}
return term;
}
function formatList(textArray) {
return textArray.join('\n').replace(/^/gm, ' '.repeat(itemIndentWidth));
}
// Usage
let output = [`Usage: ${helper.commandUsage(cmd)}`, ''];
// Description
const commandDescription = helper.commandDescription(cmd);
if (commandDescription.length > 0) {
output = output.concat([commandDescription, '']);
}
// Arguments
const argumentList = helper.visibleArguments(cmd).map(argument => formatItem(
chalk[colors[depth].arg](argument.term),
argument.description
));
if (argumentList.length > 0) {
output = output.concat(['Arguments:', formatList(argumentList), '']);
}
// Options
const optionList = helper.visibleOptions(cmd).map(option => formatItem(
chalk[colors[depth].option](helper.optionTerm(option)),
helper.optionDescription(option)
));
if (optionList.length > 0) {
output = output.concat(['Options:', formatList(optionList), '']);
}
// Commands
const commandList = helper.visibleCommands(cmd).map(cmd => formatItem(
helper.subcommandTerm(cmd),
helper.subcommandDescription(cmd)
));
if (commandList.length > 0) {
output = output.concat(['Commands:', formatList(commandList), '']);
}
return output.join('\n');
},
};

View File

@@ -1,3 +1,5 @@
/* eslint-disable import/order */
'use strict';
const fs = require('fs');

View File

@@ -52,7 +52,7 @@ controller.list = async function (req, res) {
delete data.children;
let tids = await categories.getTopicIds(cidQuery);
tids = await categories.sortTidsBySet(tids, sort); // sorting not handled if cid is -1
tids = await categories.sortTidsBySet(tids, -1, sort); // sorting not handled if cid is -1
data.topicCount = tids.length;
data.topics = await topics.getTopicsByTids(tids, { uid: req.uid });
topics.calculateTopicIndices(data.topics, start);

View File

@@ -21,8 +21,8 @@ eventsController.get = async function (req, res) {
}
// Limit by date
let from = req.query.start ? new Date(req.query.start) : undefined;
let to = req.query.end ? new Date(req.query.end) : new Date();
let from = req.query.start ? new Date(req.query.start) || undefined : undefined;
let to = req.query.end ? new Date(req.query.end) || undefined : new Date();
from = from && from.setUTCHours(0, 0, 0, 0); // setHours returns a unix timestamp (Number, not Date)
to = to && to.setUTCHours(23, 59, 59, 999); // setHours returns a unix timestamp (Number, not Date)

View File

@@ -26,25 +26,14 @@ const validSorts = [
];
categoryController.get = async function (req, res, next) {
let cid = req.params.category_id;
const cid = req.params.category_id;
if (cid === '-1') {
return helpers.redirect(res, `${res.locals.isAPI ? '/api' : ''}/world?${qs.stringify(req.query)}`);
}
if (!utils.isNumber(cid)) {
const assertion = await activitypub.actors.assertGroup([cid]);
if (!activitypub.helpers.isUri(cid)) {
cid = await db.getObjectField('handle:cid', cid);
}
if (!assertion || !cid) {
return next();
}
}
let currentPage = parseInt(req.query.page, 10) || 1;
let topicIndex = utils.isNumber(req.params.topic_index) ? parseInt(req.params.topic_index, 10) - 1 : 0;
if ((req.params.topic_index && !utils.isNumber(req.params.topic_index))) {
if ((req.params.topic_index && !utils.isNumber(req.params.topic_index)) || !utils.isNumber(cid)) {
return next();
}
@@ -69,7 +58,7 @@ categoryController.get = async function (req, res, next) {
return helpers.notAllowed(req, res);
}
if (utils.isNumber(cid) && !res.locals.isAPI && !req.params.slug && (categoryFields.slug && categoryFields.slug !== `${cid}/`)) {
if (!res.locals.isAPI && !req.params.slug && (categoryFields.slug && categoryFields.slug !== `${cid}/`)) {
return helpers.redirect(res, `/category/${categoryFields.slug}?${qs.stringify(req.query)}`, true);
}

View File

@@ -235,12 +235,6 @@ Controllers.confirmEmail = async (req, res) => {
return renderPage();
}
try {
if (req.loggedIn) {
const emailValidated = await user.getUserField(req.uid, 'email:confirmed');
if (emailValidated) {
return renderPage({ alreadyValidated: true });
}
}
await user.email.confirmByCode(req.params.code, req.session.id);
if (req.session.registration) {
// After confirmation, no need to send user back to email change form

View File

@@ -46,7 +46,7 @@ Posts.get = async (req, res) => {
Posts.getIndex = async (req, res) => {
const { pid } = req.params;
const { sort } = req.body || {};
const { sort } = req.body;
const index = await api.posts.getIndex(req, { pid, sort });
if (index === null) {

View File

@@ -106,7 +106,8 @@ events.log = async function (data) {
events.getEvents = async function (options) {
// backwards compatibility
if (arguments.length > 1) {
const args = [...arguments];
// eslint-disable-next-line prefer-rest-params
const args = Array.prototype.slice.call(arguments);
options = {
filter: args[0],
start: args[1],

View File

@@ -105,8 +105,7 @@ middleware.assertPayload = async function (req, res, next) {
// Cross-check key ownership against received actor
await activitypub.actors.assert(actor);
let compare = await db.getObjectsFields([`userRemote:${actor}:keys`, `categoryRemote:${actor}:keys`], ['id']);
compare = compare.reduce((keyId, { id }) => keyId || id, '').replace(/#[\w-]+$/, '');
const compare = ((await db.getObjectField(`userRemote:${actor}:keys`, 'id')) || '').replace(/#[\w-]+$/, '');
const { signature } = req.headers;
let keyId = new Map(signature.split(',').filter(Boolean).map((v) => {
const index = v.indexOf('=');

View File

@@ -287,9 +287,7 @@ middleware.validateAuth = helpers.try(async (req, res, next) => {
middleware.checkRequired = function (fields, req, res, next) {
// Used in API calls to ensure that necessary parameters/data values are present
const missing = fields.filter(
field => req.body && !req.body.hasOwnProperty(field) && !req.query.hasOwnProperty(field)
);
const missing = fields.filter(field => !req.body.hasOwnProperty(field) && !req.query.hasOwnProperty(field));
if (!missing.length) {
return next();

View File

@@ -62,9 +62,8 @@ module.exports = function (middleware) {
return await finishLogin(req, user);
} else if (user.hasOwnProperty('master') && user.master === true) {
// If the token received was a master token, a _uid must also be present for all calls
const body = req.body || {};
if (body.hasOwnProperty('_uid') || req.query.hasOwnProperty('_uid')) {
user.uid = body._uid || req.query._uid;
if (req.body.hasOwnProperty('_uid') || req.query.hasOwnProperty('_uid')) {
user.uid = req.body._uid || req.query._uid;
delete user.master;
return await finishLogin(req, user);
}

View File

@@ -9,7 +9,6 @@ const user = require('../user');
const categories = require('../categories');
const plugins = require('../plugins');
const translator = require('../translator');
const utils = require('../utils');
const helpers = module.exports;
@@ -20,11 +19,6 @@ const uidToSystemGroup = {
};
helpers.isUsersAllowedTo = async function (privilege, uids, cid) {
// Remote categories inherit world pseudo-category privileges
if (!utils.isNumber(cid)) {
cid = -1;
}
const [hasUserPrivilege, hasGroupPrivilege] = await Promise.all([
groups.isMembers(uids, `cid:${cid}:privileges:${privilege}`),
groups.isMembersOfGroupList(uids, `cid:${cid}:privileges:groups:${privilege}`),
@@ -35,13 +29,6 @@ helpers.isUsersAllowedTo = async function (privilege, uids, cid) {
};
helpers.isAllowedTo = async function (privilege, uidOrGroupName, cid) {
// Remote categories (non-numeric) inherit world privileges
if (Array.isArray(cid)) {
cid = cid.map(cid => (utils.isNumber(cid) ? cid : -1));
} else {
cid = utils.isNumber(cid) ? cid : -1;
}
let allowed;
if (Array.isArray(privilege) && !Array.isArray(cid)) {
allowed = await isAllowedToPrivileges(privilege, uidOrGroupName, cid);

View File

@@ -9,13 +9,13 @@ const utils = require('../../utils');
module.exports = function (SocketTopics) {
SocketTopics.isTagAllowed = async function (socket, data) {
if (!data || !data.tag) {
if (!data || !utils.isNumber(data.cid) || !data.tag) {
throw new Error('[[error:invalid-data]]');
}
const systemTags = (meta.config.systemTags || '').split(',');
const [tagWhitelist, isPrivileged] = await Promise.all([
utils.isNumber(data.cid) ? categories.getTagWhitelist([data.cid]) : [],
categories.getTagWhitelist([data.cid]),
user.isPrivileged(socket.uid),
]);
return isPrivileged ||

View File

@@ -67,7 +67,7 @@ module.exports = function (Topics) {
db.sortedSetsAdd(timestampedSortedSetKeys, timestamp, topicData.tid),
db.sortedSetsAdd(countedSortedSetKeys, 0, topicData.tid),
user.addTopicIdToUser(topicData.uid, topicData.tid, timestamp),
db.incrObjectField(`${utils.isNumber(topicData.cid) ? 'category' : 'categoryRemote'}:${topicData.cid}`, 'topic_count'),
db.incrObjectField(`category:${topicData.cid}`, 'topic_count'),
utils.isNumber(tid) ? db.incrObjectField('global', 'topicCount') : null,
Topics.createTags(data.tags, topicData.tid, timestamp),
scheduled ? Promise.resolve() : categories.updateRecentTid(topicData.cid, topicData.tid),

View File

@@ -145,8 +145,8 @@ module.exports = function (Topics) {
const postCountChange = incr * topicData.postcount;
await Promise.all([
db.incrObjectFieldBy('global', 'postCount', postCountChange),
db.incrObjectFieldBy(`${utils.isNumber(topicData.cid) ? 'category' : 'categoryRemote'}:${topicData.cid}`, 'post_count', postCountChange),
db.incrObjectFieldBy(`${utils.isNumber(topicData.cid) ? 'category' : 'categoryRemote'}:${topicData.cid}`, 'topic_count', incr),
db.incrObjectFieldBy(`category:${topicData.cid}`, 'post_count', postCountChange),
db.incrObjectFieldBy(`category:${topicData.cid}`, 'topic_count', incr),
]);
}
};

View File

@@ -74,7 +74,8 @@ Topics.getTopicsByTids = async function (tids, options) {
.map(t => t && t.uid && t.uid.toString())
.filter(v => utils.isNumber(v) || activitypub.helpers.isUri(v)));
const cids = _.uniq(topics
.map(t => t && t.cid && t.cid.toString()));
.map(t => t && t.cid && t.cid.toString())
.filter(v => utils.isNumber(v)));
const guestTopics = topics.filter(t => t && t.uid === 0);
async function loadGuestHandles() {

View File

@@ -4,7 +4,6 @@
const db = require('../database');
const plugins = require('../plugins');
const posts = require('../posts');
const utils = require('../utils');
module.exports = function (Topics) {
const terms = {
@@ -76,7 +75,7 @@ module.exports = function (Topics) {
// Topics in /world are excluded from /recent
const cid = await Topics.getTopicField(tid, 'cid');
if (!utils.isNumber(cid) || cid === -1) {
if (cid === -1) {
return await db.sortedSetRemove('topics:recent', data.tid);
}

View File

@@ -233,7 +233,7 @@ module.exports = function (Topics) {
};
topicTools.move = async function (tid, data) {
const cid = utils.isNumber(data.cid) ? parseInt(data.cid, 10) : data.cid;
const cid = parseInt(data.cid, 10);
const topicData = await Topics.getTopicData(tid);
if (!topicData) {
throw new Error('[[error:no-topic]]');

View File

@@ -146,6 +146,7 @@ Upgrade.process = async function (files, skipCount) {
process.stdout.write(chalk.grey(' skipped\n'));
await db.sortedSetAdd('schemaLog', Date.now(), path.basename(file, '.js'));
// eslint-disable-next-line no-continue
continue;
}

View File

@@ -1,3 +1,5 @@
/* eslint-disable no-await-in-loop */
'use strict';
const db = require('../../database');

View File

@@ -1,3 +1,5 @@
/* eslint-disable no-await-in-loop */
'use strict';
const db = require('../../database');

View File

@@ -76,7 +76,7 @@ module.exports = {
batch: 500,
withScores: true,
});
// eslint-disable-next-line no-await-in-loop
await db.deleteAll(`uid:${uid}:chat:room:${roomId}:mids`);
progress.incr(1);
}

View File

@@ -1,3 +1,5 @@
/* eslint-disable no-await-in-loop */
'use strict';
const db = require('../../database');

View File

@@ -1,3 +1,5 @@
/* eslint-disable no-await-in-loop */
'use strict';
const db = require('../../database');

View File

@@ -1,3 +1,5 @@
/* eslint-disable no-await-in-loop */
'use strict';
const db = require('../../database');

View File

@@ -1,3 +1,5 @@
/* eslint-disable no-await-in-loop */
'use strict';
const db = require('../../database');

View File

@@ -1,3 +1,5 @@
/* eslint-disable no-await-in-loop */
'use strict';
const db = require('../../database');

View File

@@ -1,3 +1,5 @@
/* eslint-disable no-await-in-loop */
'use strict';
const db = require('../../database');

View File

@@ -1,3 +1,5 @@
/* eslint-disable no-await-in-loop */
'use strict';
const db = require('../../database');

View File

@@ -1,3 +1,5 @@
/* eslint-disable no-await-in-loop */
'use strict';
const db = require('../../database');

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-await-in-loop */
'use strict';

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-await-in-loop */
'use strict';

View File

@@ -3,10 +3,8 @@
const _ = require('lodash');
const db = require('../database');
const meta = require('../meta');
const categories = require('../categories');
const plugins = require('../plugins');
const api = require('../api');
const utils = require('../utils');
module.exports = function (User) {
@@ -29,18 +27,7 @@ module.exports = function (User) {
if (exists.includes(false)) {
throw new Error('[[error:no-category]]');
}
const apiMethod = state >= categories.watchStates.tracking ? 'follow' : 'unfollow';
const follows = cids.filter(cid => !utils.isNumber(cid)).map(cid => api.activitypub[apiMethod]({ uid }, {
type: 'uid',
id: uid,
actor: cid,
})); // returns promises
await Promise.all([
db.sortedSetsAdd(cids.map(cid => `cid:${cid}:uid:watch:state`), state, uid),
...follows,
]);
await db.sortedSetsAdd(cids.map(cid => `cid:${cid}:uid:watch:state`), state, uid);
};
User.getCategoryWatchState = async function (uid) {
@@ -80,11 +67,7 @@ module.exports = function (User) {
};
User.getCategoriesByStates = async function (uid, states) {
const [localCids, remoteCids] = await Promise.all([
categories.getAllCidsFromSet('categories:cid'),
meta.config.activitypubEnabled ? db.getObjectValues('handle:cid') : [],
]);
const cids = localCids.concat(remoteCids);
const cids = await categories.getAllCidsFromSet('categories:cid');
if (!(parseInt(uid, 10) > 0)) {
return cids;
}

Some files were not shown because too many files have changed in this diff Show More