Repository: Azareal/Gosora Branch: master Commit: 553e66b59003 Files: 695 Total size: 2.9 MB Directory structure: gitextract_v2tdrp1t/ ├── .codebeatignore ├── .codeclimate.yml ├── .eslintrc.json ├── .gitignore ├── .htaccess ├── .travis.yml ├── .vscode/ │ └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── TODO.md ├── attachs/ │ └── filler.txt ├── backups/ │ └── filler.txt ├── build-linux ├── build-linux-nowebsockets ├── build-nowebsockets.bat ├── build.bat ├── build_templates.bat ├── cmd/ │ ├── common_hook_gen/ │ │ └── hookgen.go │ ├── elasticsearch/ │ │ └── setup.go │ ├── hook_gen/ │ │ └── main.go │ ├── hook_stub_gen/ │ │ └── main.go │ ├── install/ │ │ └── install.go │ └── query_gen/ │ ├── build.bat │ ├── main.go │ ├── run.bat │ ├── spitter.go │ └── tables.go ├── common/ │ ├── activity_stream.go │ ├── activity_stream_matches.go │ ├── alerts/ │ │ └── tmpls.go │ ├── alerts.go │ ├── analytics.go │ ├── attachments.go │ ├── audit_logs.go │ ├── auth.go │ ├── cache.go │ ├── common.go │ ├── common_easyjson.tgo │ ├── conversations.go │ ├── convos_posts.go │ ├── counters/ │ │ ├── agents.go │ │ ├── common.go │ │ ├── forums.go │ │ ├── langs.go │ │ ├── memory.go │ │ ├── performance.go │ │ ├── posts.go │ │ ├── referrers.go │ │ ├── requests.go │ │ ├── routes.go │ │ ├── systems.go │ │ ├── topics.go │ │ └── topics_views.go │ ├── disk.go │ ├── email.go │ ├── email_store.go │ ├── errors.go │ ├── extend.go │ ├── files.go │ ├── forum.go │ ├── forum_actions.go │ ├── forum_perms.go │ ├── forum_perms_store.go │ ├── forum_store.go │ ├── gauth/ │ │ └── authenticator.go │ ├── group.go │ ├── group_store.go │ ├── ip_search.go │ ├── likes.go │ ├── menu_item_store.go │ ├── menu_store.go │ ├── menus.go │ ├── meta/ │ │ └── meta_store.go │ ├── mfa_store.go │ ├── misc_logs.go │ ├── module_ottojs.go │ ├── no_websockets.go │ ├── null_reply_cache.go │ ├── null_topic_cache.go │ ├── null_user_cache.go │ ├── page_store.go │ ├── pages.go │ ├── parser.go │ ├── password_reset.go │ ├── permissions.go │ ├── phrases/ │ │ └── phrases.go │ ├── pluginlangs.go │ ├── poll.go │ ├── poll_cache.go │ ├── poll_store.go │ ├── profile_reply.go │ ├── profile_reply_store.go │ ├── promotions.go │ ├── ratelimit.go │ ├── recalc.go │ ├── relations.go │ ├── reply.go │ ├── reply_cache.go │ ├── reply_store.go │ ├── report_store.go │ ├── routes_common.go │ ├── search.go │ ├── settings.go │ ├── site.go │ ├── statistics.go │ ├── subscription.go │ ├── tasks.go │ ├── template_init.go │ ├── templates/ │ │ ├── context.go │ │ ├── minifiers.go │ │ └── templates.go │ ├── thaw.go │ ├── theme.go │ ├── theme_list.go │ ├── thumbnailer.go │ ├── tickloop.go │ ├── topic.go │ ├── topic_cache.go │ ├── topic_list.go │ ├── topic_store.go │ ├── user.go │ ├── user_cache.go │ ├── user_store.go │ ├── utils.go │ ├── weak_passwords.go │ ├── websockets.go │ ├── widget.go │ ├── widget_search_and_filter.go │ ├── widget_store.go │ ├── widget_wol.go │ ├── widget_wol_context.go │ ├── widgets.go │ ├── word_filters.go │ ├── ws_hub.go │ └── ws_user.go ├── config/ │ ├── config_example.json │ ├── emoji_default.json │ ├── filler.txt │ └── weakpass_default.json ├── database.go ├── dev-update-linux ├── dev-update-travis ├── dev-update.bat ├── docs/ │ ├── configuration.md │ ├── custom_pages.md │ ├── emoji.md │ ├── installation.md │ ├── internationalisation.md │ ├── landing_page.md │ ├── templates.md │ ├── updating.md │ └── weak_passwords.md ├── experimental/ │ ├── config.json │ ├── counterTree/ │ │ ├── tree.go │ │ └── tree_test.go │ ├── module_lua.go │ ├── module_v8js.go │ ├── new-replybit.html │ ├── new-update.bat │ ├── plugin_geoip.go │ ├── plugin_sendmail.go │ ├── theme-ext.json │ └── theme-ext.xml ├── extend/ │ ├── adventure/ │ │ ├── lib/ │ │ │ ├── adventure.go │ │ │ └── adventure_store.go │ │ ├── plugin.json │ │ └── prebuild/ │ │ └── filler.txt │ ├── filler.go │ ├── guilds/ │ │ ├── lib/ │ │ │ ├── guild_store.go │ │ │ └── guilds.go │ │ ├── plugin.json │ │ ├── plugin_guilds.go │ │ └── prebuild/ │ │ └── filler.txt │ ├── heytherejs/ │ │ ├── main.js │ │ └── plugin.json │ ├── plugin_adventure.go │ ├── plugin_bbcode.go │ ├── plugin_heythere.go │ ├── plugin_hyperdrive.go │ ├── plugin_markdown.go │ └── plugin_skeleton.go ├── gen_mssql.go ├── gen_mysql.go ├── gen_pgsql.go ├── gen_router.go ├── gen_tables.go ├── general_test.go ├── go.mod ├── go.sum ├── gosora_example.service ├── install/ │ ├── install.go │ ├── mssql.go │ ├── mysql.go │ ├── pgsql.go │ └── utils.go ├── install-docker ├── install-linux ├── install.bat ├── langs/ │ └── english.json ├── last_version.txt ├── logs/ │ └── filler.txt ├── main.go ├── migrations/ │ └── filler.txt ├── misc_test.go ├── mssql.go ├── mysql.go ├── old_router.go ├── pages/ │ └── page_test.html ├── parser_test.go ├── patcher/ │ ├── main.go │ ├── patches.go │ └── utils.go ├── pgsql.go ├── plugin_test.go ├── pre-run-linux ├── public/ │ ├── EQCSS.js │ ├── Sortable-1.4.0/ │ │ ├── .editorconfig │ │ ├── .gitignore │ │ ├── .jshintrc │ │ ├── CONTRIBUTING.md │ │ ├── README.md │ │ ├── Sortable.js │ │ ├── bower.json │ │ ├── component.json │ │ ├── jquery.binding.js │ │ └── package.json │ ├── account.js │ ├── analytics.js │ ├── chartist/ │ │ ├── chartist-plugin-legend.css │ │ └── chartist.css │ ├── convo.js │ ├── font-awesome-4.7.0/ │ │ └── fonts/ │ │ └── FontAwesome.otf │ ├── global.js │ ├── init.js │ ├── jquery-emojiarea/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── emojis.js │ │ ├── jquery.emojiarea.css │ │ └── jquery.emojiarea.js │ ├── member.js │ ├── panel_forum_edit.js │ ├── panel_forums.js │ ├── panel_menu_items.js │ ├── profile_member.js │ ├── register.js │ ├── templates/ │ │ └── filler.txt │ ├── trumbowyg/ │ │ └── ui/ │ │ ├── trumbowyg.css │ │ └── trumbowyg.custom.css │ └── widgets.js ├── pubnot/ │ ├── chartist/ │ │ ├── chartist-plugin-legend.css │ │ ├── chartist.css │ │ ├── chartist.js │ │ └── scss/ │ │ ├── chartist.scss │ │ └── settings/ │ │ └── _chartist-settings.scss │ ├── font-awesome-4.7.0/ │ │ ├── css/ │ │ │ └── font-awesome.css │ │ └── fonts/ │ │ └── FontAwesome.otf │ └── trumbowyg/ │ ├── plugins/ │ │ ├── base64/ │ │ │ └── trumbowyg.base64.js │ │ ├── cleanpaste/ │ │ │ └── trumbowyg.cleanpaste.js │ │ ├── colors/ │ │ │ ├── trumbowyg.colors.js │ │ │ └── ui/ │ │ │ ├── sass/ │ │ │ │ └── trumbowyg.colors.scss │ │ │ └── trumbowyg.colors.css │ │ ├── emoji/ │ │ │ ├── trumbowyg.emoji.js │ │ │ └── ui/ │ │ │ ├── sass/ │ │ │ │ └── trumbowyg.emoji.scss │ │ │ └── trumbowyg.emoji.css │ │ ├── insertaudio/ │ │ │ └── trumbowyg.insertaudio.js │ │ ├── noembed/ │ │ │ └── trumbowyg.noembed.js │ │ ├── pasteimage/ │ │ │ └── trumbowyg.pasteimage.js │ │ ├── preformatted/ │ │ │ └── trumbowyg.preformatted.js │ │ ├── table/ │ │ │ └── trumbowyg.table.js │ │ ├── template/ │ │ │ └── trumbowyg.template.js │ │ └── upload/ │ │ └── trumbowyg.upload.js │ ├── trumbowyg.js │ └── ui/ │ ├── sass/ │ │ └── trumbowyg.scss │ ├── trumbowyg.css │ └── trumbowyg.custom.css ├── query_gen/ │ ├── acc_builders.go │ ├── accumulator.go │ ├── builder.go │ ├── install.go │ ├── micro_builders.go │ ├── mssql.go │ ├── mysql.go │ ├── pgsql.go │ ├── querygen.go │ ├── transaction.go │ ├── utils.go │ └── utils_test.go ├── quick-update-linux ├── rev_templates.go ├── router.go ├── router_gen/ │ ├── build.bat │ ├── main.go │ ├── misc_test.go │ ├── prec.go │ ├── route_group.go │ ├── route_impl.go │ ├── route_subset.go │ ├── router.go │ ├── routes.go │ └── run.bat ├── routes/ │ ├── account.go │ ├── api.go │ ├── attachments.go │ ├── common.go │ ├── convos.go │ ├── forum.go │ ├── forum_list.go │ ├── misc.go │ ├── moderate.go │ ├── panel/ │ │ ├── analytics.go │ │ ├── backups.go │ │ ├── common.go │ │ ├── dashboard.go │ │ ├── debug.go │ │ ├── forums.go │ │ ├── groups.go │ │ ├── logs.go │ │ ├── pages.go │ │ ├── plugins.go │ │ ├── settings.go │ │ ├── themes.go │ │ ├── users.go │ │ └── word_filters.go │ ├── poll.go │ ├── profile.go │ ├── profile_reply.go │ ├── reply.go │ ├── reports.go │ ├── stubs.go │ ├── topic.go │ ├── topic_list.go │ └── user.go ├── routes.go ├── run-linux ├── run-linux-nowebsockets ├── run-linux-tests ├── run-nowebsockets.bat ├── run.bat ├── run_mssql.bat ├── run_tests.bat ├── run_tests_mssql.bat ├── schema/ │ ├── mssql/ │ │ ├── inserts.sql │ │ ├── query_activity_stream.sql │ │ ├── query_activity_stream_matches.sql │ │ ├── query_activity_subscriptions.sql │ │ ├── query_administration_logs.sql │ │ ├── query_attachments.sql │ │ ├── query_conversations.sql │ │ ├── query_conversations_participants.sql │ │ ├── query_conversations_posts.sql │ │ ├── query_emails.sql │ │ ├── query_forums.sql │ │ ├── query_forums_actions.sql │ │ ├── query_forums_permissions.sql │ │ ├── query_likes.sql │ │ ├── query_login_logs.sql │ │ ├── query_memchunks.sql │ │ ├── query_menu_items.sql │ │ ├── query_menus.sql │ │ ├── query_meta.sql │ │ ├── query_moderation_logs.sql │ │ ├── query_pages.sql │ │ ├── query_password_resets.sql │ │ ├── query_perfchunks.sql │ │ ├── query_plugins.sql │ │ ├── query_polls.sql │ │ ├── query_polls_options.sql │ │ ├── query_polls_voters.sql │ │ ├── query_polls_votes.sql │ │ ├── query_postchunks.sql │ │ ├── query_registration_logs.sql │ │ ├── query_replies.sql │ │ ├── query_revisions.sql │ │ ├── query_settings.sql │ │ ├── query_sync.sql │ │ ├── query_themes.sql │ │ ├── query_topicchunks.sql │ │ ├── query_topics.sql │ │ ├── query_updates.sql │ │ ├── query_users.sql │ │ ├── query_users_2fa_keys.sql │ │ ├── query_users_avatar_queue.sql │ │ ├── query_users_blocks.sql │ │ ├── query_users_groups.sql │ │ ├── query_users_groups_promotions.sql │ │ ├── query_users_groups_scheduler.sql │ │ ├── query_users_replies.sql │ │ ├── query_viewchunks.sql │ │ ├── query_viewchunks_agents.sql │ │ ├── query_viewchunks_forums.sql │ │ ├── query_viewchunks_langs.sql │ │ ├── query_viewchunks_referrers.sql │ │ ├── query_viewchunks_systems.sql │ │ ├── query_widgets.sql │ │ └── query_word_filters.sql │ ├── mysql/ │ │ ├── inserts.sql │ │ ├── query_activity_stream.sql │ │ ├── query_activity_stream_matches.sql │ │ ├── query_activity_subscriptions.sql │ │ ├── query_administration_logs.sql │ │ ├── query_attachments.sql │ │ ├── query_conversations.sql │ │ ├── query_conversations_participants.sql │ │ ├── query_conversations_posts.sql │ │ ├── query_emails.sql │ │ ├── query_forums.sql │ │ ├── query_forums_actions.sql │ │ ├── query_forums_permissions.sql │ │ ├── query_likes.sql │ │ ├── query_login_logs.sql │ │ ├── query_memchunks.sql │ │ ├── query_menu_items.sql │ │ ├── query_menus.sql │ │ ├── query_meta.sql │ │ ├── query_moderation_logs.sql │ │ ├── query_pages.sql │ │ ├── query_password_resets.sql │ │ ├── query_perfchunks.sql │ │ ├── query_plugins.sql │ │ ├── query_polls.sql │ │ ├── query_polls_options.sql │ │ ├── query_polls_voters.sql │ │ ├── query_polls_votes.sql │ │ ├── query_postchunks.sql │ │ ├── query_registration_logs.sql │ │ ├── query_replies.sql │ │ ├── query_revisions.sql │ │ ├── query_settings.sql │ │ ├── query_sync.sql │ │ ├── query_themes.sql │ │ ├── query_topicchunks.sql │ │ ├── query_topics.sql │ │ ├── query_updates.sql │ │ ├── query_users.sql │ │ ├── query_users_2fa_keys.sql │ │ ├── query_users_avatar_queue.sql │ │ ├── query_users_blocks.sql │ │ ├── query_users_groups.sql │ │ ├── query_users_groups_promotions.sql │ │ ├── query_users_groups_scheduler.sql │ │ ├── query_users_replies.sql │ │ ├── query_viewchunks.sql │ │ ├── query_viewchunks_agents.sql │ │ ├── query_viewchunks_forums.sql │ │ ├── query_viewchunks_langs.sql │ │ ├── query_viewchunks_referrers.sql │ │ ├── query_viewchunks_systems.sql │ │ ├── query_widgets.sql │ │ └── query_word_filters.sql │ ├── pgsql/ │ │ ├── inserts.sql │ │ ├── query_activity_stream.sql │ │ ├── query_activity_stream_matches.sql │ │ ├── query_activity_subscriptions.sql │ │ ├── query_administration_logs.sql │ │ ├── query_attachments.sql │ │ ├── query_conversations.sql │ │ ├── query_conversations_participants.sql │ │ ├── query_conversations_posts.sql │ │ ├── query_emails.sql │ │ ├── query_forums.sql │ │ ├── query_forums_actions.sql │ │ ├── query_forums_permissions.sql │ │ ├── query_likes.sql │ │ ├── query_login_logs.sql │ │ ├── query_memchunks.sql │ │ ├── query_menu_items.sql │ │ ├── query_menus.sql │ │ ├── query_meta.sql │ │ ├── query_moderation_logs.sql │ │ ├── query_pages.sql │ │ ├── query_password_resets.sql │ │ ├── query_perfchunks.sql │ │ ├── query_plugins.sql │ │ ├── query_polls.sql │ │ ├── query_polls_options.sql │ │ ├── query_polls_votes.sql │ │ ├── query_postchunks.sql │ │ ├── query_registration_logs.sql │ │ ├── query_replies.sql │ │ ├── query_revisions.sql │ │ ├── query_settings.sql │ │ ├── query_sync.sql │ │ ├── query_themes.sql │ │ ├── query_topicchunks.sql │ │ ├── query_topics.sql │ │ ├── query_updates.sql │ │ ├── query_users.sql │ │ ├── query_users_2fa_keys.sql │ │ ├── query_users_avatar_queue.sql │ │ ├── query_users_blocks.sql │ │ ├── query_users_groups.sql │ │ ├── query_users_groups_promotions.sql │ │ ├── query_users_groups_scheduler.sql │ │ ├── query_users_replies.sql │ │ ├── query_viewchunks.sql │ │ ├── query_viewchunks_agents.sql │ │ ├── query_viewchunks_forums.sql │ │ ├── query_viewchunks_langs.sql │ │ ├── query_viewchunks_referrers.sql │ │ ├── query_viewchunks_systems.sql │ │ ├── query_widgets.sql │ │ └── query_word_filters.sql │ └── schema.json ├── templates/ │ ├── account.html │ ├── account_blocked.html │ ├── account_logins.html │ ├── account_menu.html │ ├── account_own_edit.html │ ├── account_own_edit_email.html │ ├── account_own_edit_level.html │ ├── account_own_edit_mfa.html │ ├── account_own_edit_mfa_setup.html │ ├── account_own_edit_password.html │ ├── account_own_edit_privacy.html │ ├── account_test.html │ ├── alert.html │ ├── are_you_sure.html │ ├── convo.html │ ├── convo_row.html │ ├── convo_row_alt.html │ ├── convos.html │ ├── create_convo.html │ ├── create_topic.html │ ├── custom_page.html │ ├── error.html │ ├── footer.html │ ├── forum.html │ ├── forum_gallery.html │ ├── forums.html │ ├── guilds_create_guild.html │ ├── guilds_css.html │ ├── guilds_guild_list.html │ ├── guilds_member_list.html │ ├── guilds_view_guild.html │ ├── header.html │ ├── ip_search.html │ ├── level_list.html │ ├── login.html │ ├── login_mfa_verify.html │ ├── menu_alerts.html │ ├── menu_item.html │ ├── notice.html │ ├── overrides/ │ │ └── filler.txt │ ├── overview.html │ ├── paginator.html │ ├── paginator_mod.html │ ├── panel.html │ ├── panel_adminlogs.html │ ├── panel_analytics_active_memory.html │ ├── panel_analytics_agent_views.html │ ├── panel_analytics_agents.html │ ├── panel_analytics_forum_views.html │ ├── panel_analytics_forums.html │ ├── panel_analytics_lang_views.html │ ├── panel_analytics_langs.html │ ├── panel_analytics_memory.html │ ├── panel_analytics_performance.html │ ├── panel_analytics_posts.html │ ├── panel_analytics_referrer_views.html │ ├── panel_analytics_referrers.html │ ├── panel_analytics_route_views.html │ ├── panel_analytics_routes.html │ ├── panel_analytics_routes_perf.html │ ├── panel_analytics_script.html │ ├── panel_analytics_script_memory.html │ ├── panel_analytics_script_perf.html │ ├── panel_analytics_system_views.html │ ├── panel_analytics_systems.html │ ├── panel_analytics_time_range.html │ ├── panel_analytics_time_range_month.html │ ├── panel_analytics_topics.html │ ├── panel_analytics_views.html │ ├── panel_are_you_sure.html │ ├── panel_backups.html │ ├── panel_before_head.html │ ├── panel_dashboard.html │ ├── panel_debug.html │ ├── panel_debug_stat.html │ ├── panel_debug_stat_head.html │ ├── panel_debug_stat_head_q.html │ ├── panel_debug_stat_q.html │ ├── panel_debug_subhead.html │ ├── panel_forum_edit.html │ ├── panel_forum_edit_perms.html │ ├── panel_forums.html │ ├── panel_group_edit.html │ ├── panel_group_edit_perms.html │ ├── panel_group_edit_promotions.html │ ├── panel_group_menu.html │ ├── panel_groups.html │ ├── panel_inner_menu.html │ ├── panel_menu.html │ ├── panel_modlogs.html │ ├── panel_pages.html │ ├── panel_pages_edit.html │ ├── panel_plugins.html │ ├── panel_reglogs.html │ ├── panel_setting.html │ ├── panel_settings.html │ ├── panel_themes.html │ ├── panel_themes_menus.html │ ├── panel_themes_menus_item_edit.html │ ├── panel_themes_menus_items.html │ ├── panel_themes_widgets.html │ ├── panel_themes_widgets_widget.html │ ├── panel_user_edit.html │ ├── panel_users.html │ ├── panel_word_filters.html │ ├── password_reset.html │ ├── password_reset_token.html │ ├── profile.html │ ├── profile_comments_row.html │ ├── profile_comments_row_alt.html │ ├── register.html │ ├── register_verify.html │ ├── topic.html │ ├── topic_alt.html │ ├── topic_alt_inner.html │ ├── topic_alt_mini.html │ ├── topic_alt_poll.html │ ├── topic_alt_posts.html │ ├── topic_alt_quick_reply.html │ ├── topic_alt_userinfo.html │ ├── topic_c_attach_item.html │ ├── topic_c_edit_post.html │ ├── topic_c_poll_input.html │ ├── topic_inner.html │ ├── topic_mini.html │ ├── topic_poll.html │ ├── topic_posts.html │ ├── topics.html │ ├── topics_inner.html │ ├── topics_mini.html │ ├── topics_mod_floater.html │ ├── topics_quick_topic.html │ ├── topics_topic.html │ ├── widget_about.html │ ├── widget_menu.html │ ├── widget_online.html │ ├── widget_search_and_filter.html │ └── widget_simple.html ├── themes/ │ ├── cosora/ │ │ ├── public/ │ │ │ ├── account.css │ │ │ ├── convo.css │ │ │ ├── main.css │ │ │ ├── misc.js │ │ │ ├── panel.css │ │ │ └── profile.css │ │ └── theme.json │ ├── nox/ │ │ ├── overrides/ │ │ │ ├── login.html │ │ │ ├── panel_before_head.html │ │ │ ├── panel_group_menu.html │ │ │ ├── panel_inner_menu.html │ │ │ ├── panel_menu.html │ │ │ ├── profile_comments_row.html │ │ │ └── topics_topic.html │ │ ├── public/ │ │ │ ├── acc_panel_common.css │ │ │ ├── account.css │ │ │ ├── convo.css │ │ │ ├── fa-svg/ │ │ │ │ ├── LICENSE.txt │ │ │ │ └── README.md │ │ │ ├── main.css │ │ │ ├── misc.js │ │ │ ├── panel.css │ │ │ └── profile.css │ │ └── theme.json │ ├── shadow/ │ │ ├── DEVELOPERS.md │ │ ├── overrides/ │ │ │ └── login.html │ │ ├── public/ │ │ │ ├── account.css │ │ │ ├── convo.css │ │ │ ├── main.css │ │ │ ├── misc.js │ │ │ ├── panel.css │ │ │ └── profile.css │ │ └── theme.json │ └── tempra_simple/ │ ├── DEVELOPERS.md │ ├── overrides/ │ │ └── login.html │ ├── public/ │ │ ├── account.css │ │ ├── convo.css │ │ ├── main.css │ │ ├── media.partial.css │ │ ├── misc.js │ │ ├── panel.css │ │ ├── profile.css │ │ └── sample.css │ └── theme.json ├── tickloop.go ├── tmp/ │ └── filler.txt ├── tmpl_client/ │ └── stub.go ├── tmplstub.go ├── update-deps-linux ├── update-deps.bat ├── updater/ │ └── main.go ├── uploads/ │ └── filler.txt └── uutils/ └── utils.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .codebeatignore ================================================ /public/chartist/** /public/trumbowyg/** /public/jquery-emojiarea/** /public/font-awesome-4.7.0/** /public/jquery-3.1.1.min.js /public/EQCSS.min.js /public/EQCSS.js /schema/** tmpl_list.go tmpl_forum.go tmpl_forums.go tmpl_topic.go tmpl_topic_alt.go tmpl_topics.go tmpl_profile.go gen_mysql.go gen_mssql.go gen_pgsql.go gen_router.go ================================================ FILE: .codeclimate.yml ================================================ exclude_patterns: - "gen_*" - "schema/*" - "public/chartist/*" - "public/trumbowyg/*" - "public/jquery-emojiarea/*" - "public/font-awesome-4.7.0/*" - "public/jquery-3.1.1.min.js" - "public/EQCSS.min.js" - "public/EQCSS.js" - "public/Sortable-1.4.0/*" ================================================ FILE: .eslintrc.json ================================================ { "env": { "browser": true, "commonjs": true, "es6": true, "node": false }, "parserOptions": { "ecmaFeatures": { "jsx": true }, "sourceType": "module" }, "rules": { "no-const-assign": "warn", "no-this-before-super": "warn", "no-undef": "warn", "no-unreachable": "warn", "no-unused-vars": "warn", "constructor-super": "warn", "valid-typeof": "warn" }, "globals": { "$": true, "addHook": true, "runHook": true, "addInitHook": true, "runInitHook": true, "loadScript": true } } ================================================ FILE: .gitignore ================================================ tmp/* !tmp/filler.txt tmp2/* cert_test/* tmp.txt run_notemplategen.bat brun.bat attachs/* !attachs/filler.txt uploads/avatar_* uploads/socialgroup_* backups/*.sql logs/*.log config/config.json node_modules/* samples/vue/node_modules/* samples/vue/* bin/* out/* *.exe *.exe~ *.prof *.log .DS_Store .vscode/launch.json config/config.go QueryGen RouterGen Patcher Gosora Installer tmpl_*.go tmpl_*.jgo ================================================ FILE: .htaccess ================================================ # Gosora doesn't use Apache, this file is just here to stop Apache from blindly serving our config files, etc. when this program isn't intended to be served in such a manner at all deny from all ================================================ FILE: .travis.yml ================================================ language: go go: - "1.13" - "1.14" - "1.15" - "1.16" - master before_install: - cd $HOME - git clone https://github.com/Azareal/Gosora gosora - cd gosora - chmod -R 0777 . - mv ./config/config_example.json ./config/config.json - ./update-deps-linux - ./dev-update-travis - mv ./experimental/plugin_sendmail.go .. install: true before_script: - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter - chmod +x ./cc-test-reporter - ./cc-test-reporter before-build script: ./run-linux-tests after_script: - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT addons: mariadb: '10.3' ================================================ FILE: .vscode/settings.json ================================================ // Place your settings in this file to overwrite default and user settings. { "editor.insertSpaces": false } ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing First and foremost, if you want to add a contribution, you'll have to open a pull request and to sign the CLA (contributor level agreement). It's mainly there to deal with any legal issues which may come our way and to switch licenses without having to track down every contributor who has ever contributed. Some things we could do is commercial licensing for companies which are not authorised to use open source licenses or moving to a more permissive license, although I'm not too experianced in these matters, if anyone has any ideas, then feel free to put them forward. Try to prefix commits which introduce a lot of bugs or otherwise has a large impact on the usability of Gosora with UNSTABLE. If something seems to be strange, then feel free to bring up an alternative for it, although I'd rather not get hung up on the little details, if it's something which is purely a matter of opinion. # Coding Standards All code must be unit tested where ever possible with the exception of JavaScript which is untestable with our current technologies, tread with caution there. Use tabs not spaces for indentation. # Golang Use the standard linter and listen to what it tells you to do. The route assignments in main.go are *legacy code*, add new routes to `router_gen/routes.go` instead. Try to use the single responsibility principle where ever possible, with the exception for if doing so will cause a large performance drop. In other words, don't give your interfaces / structs too many responsibilities, keep them simple. Avoid hand-rolling queries. Use the builders, a ready built statement or a datastore structure instead. Preferably a datastore. Commits which require the patcher / update script to be run should be prefixed with "Database Changes: " More coming up. # JavaScript Use semicolons at the end of statements. If you don't, you might wind up breaking a minifier or two. Always use strict mode. Don't worry about ES5, we're targetting modern browsers. If we decide to backport code to older browsers, then we'll transpile the files. Please don't use await. It incurs too much of a cognitive overhead as to where and when you can use it. We can't use it everywhere quite yet, which means that we really should be using it nowhere. Please don't abuse `const` just to shave off a few nanoseconds. Even in the Go server where I care about performance the most, I don't use const everywhere, only in about five spots in thirty thousand lines and I don't use it for performance at all there. To keep consistency with Go code, variables must be camelCase. # JSON To keep consistency with Go code, map keys must be camelCase. # Phrases Try to keep the name of the phrase close to the actual phrase in english to make it easier for localisers to reason about which phrase is which. ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. {one line to give the program's name and a brief idea of what it does.} Copyright (C) {year} {name of author} This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: {project} Copyright (C) {year} {fullname} This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: README.md ================================================ # Gosora ![Build Status](https://travis-ci.org/Azareal/Gosora.svg?branch=master) [![Azareal's Discord Chat](https://img.shields.io/badge/style-Invite-7289DA.svg?style=flat&label=Discord)](https://discord.gg/eyYvtTf) A super fast forum software written in Go. You can talk to us on our Discord chat! The initial code-base was forked from one of my side projects, but has now gone far beyond that. We've moved along in a development and the software should be somewhat stable for general use. Features may break from time to time, however I will generally try to warn of the biggest offenders in advance, so that you can tread with caution around certain commits, the upcoming v0.1 will undergo even more rigorous testing. File an issue or open a topic on the forum, if there's something you want and you very well might find it landing in the software fairly quickly. For plugin and theme developers, things are a little dicier, as the internal APIs and ways of writing themes are in constant flux, however some stability in that area should be coming fairly soon. If you like this software, please give it a star and give us some feedback :) If you dislike it, please give us some feedback on how to make it better! We're always looking for feedback. We love hearing your opinions. If there's something missing or something doesn't look quite right, don't worry! We plan to add many, many things in the run up to v0.1! # Features Standard Forum Functionality. All of the little things you would expect of any forum software. E.g. Common Moderation features, modlogs, theme system, avatars, bbcode parser, markdown parser, report system, per-forum permissions, group permissions and so on. Custom Pages. There are some rough edges Emojis. Allow your users to express themselves without resorting to serving tons upon tons of image files. In-memory static file, forum and group caches. We have a slightly more dynamic cache for users and topics. A profile system, including profile comments and moderation tools for the profile owner. A template engine which compiles templates down to machine code. Over forty times faster than the standard template library `html/templates`, although it does remove some of the hand holding to achieve this. Compatible with templates written for `html/templates`, so you don't need to learn any new templating language. A plugin system. We have a number of APIs and hooks for plugins, however they're currently subject to change and don't cover as much of the software as we'd like yet. A responsive design. Looks great on mobile phones, tablets, laptops, desktops and more! Other modern features like alerts, likes, advanced dashboard with live stats (CPU, RAM, online user count, and so on), etc. # Requirements Go 1.13 or newer - You will need to install this. Pick the .msi, if you want everything sorted out for you rather than having to go around updating the environment settings. https://golang.org/doc/install For Ubuntu, you can consult: https://tecadmin.net/install-go-on-ubuntu/ You will also want to run `ln -s /usr/local/go/bin/go` (replace /usr/local with where ever you put Go), so that go becomes visible to other users. If you followed the instructions above, you can update to the latest version of Go simply by deleting the `/go/` folder and replacing it with a `/go/` folder for the latest version of Go. Git - You may need this for downloading updates via the updater. You might already have this installed on your server, if the `git` commands don't work, then install this. https://git-scm.com/downloads MySQL Database - You will need to setup a MySQL Database somewhere. A MariaDB Database works equally well and is much faster than MySQL. You could use something like WNMP / XAMPP which have a little PHP script called PhpMyAdmin for managing MySQL databases or you could install MariaDB directly. Download the .msi installer from [MariaDB](https://mariadb.com/downloads) and run that. You may want to set it up as a service to avoid running it every-time the computer starts up. Instructions on how to set MariaDB up on Linux: https://downloads.mariadb.org/mariadb/repositories/ We recommend changing the root password (that is the password for the user 'root'). Remember that password, you will need it for the installation process. Of course, we would advise using a user other than root for maximum security, although that adds additional steps to the process of getting everything setup. You might also want to run `mysql_secure_installation` to further harden (aka make it more secure) MySQL / MariaDB. If you're using Ubuntu, you might want to look at: https://www.itzgeek.com/how-tos/linux/ubuntu-how-tos/install-mariadb-on-ubuntu-16-04.html It's entirely possible that your host already has MySQL installed and ready to go, so you might be able to skip this step, particularly if it's a managed VPS or a shared host. Or they might have a quicker and easier method of setting up MySQL. # How to download For Linux, you can skip down to the Installation section as it covers this. On Windows, you might want to try the [GosoraBootstrapper](https://github.com/Azareal/GosoraBootstrapper), if you can't find the command prompt or otherwise can't follow those instructions. It's just a matter of double-clicking on the bat file there and it'll download the rest of the files for you. # Installation Consult [installation](https://github.com/Azareal/Gosora/blob/master/docs/installation.md) for instructions on how to install Gosora. # Updating Consult [updating](https://github.com/Azareal/Gosora/blob/master/docs/updating.md) for instructions on how to update Gosora. # Running the program *Linux* If you have setup a service, you can run: `./pre-run-linux` `service gosora start` You can then, check Gosora's current status (to see if it started up properly) with: `service gosora status` And you can stop it with: `service gosora stop` If you haven't setup a service, you can run `./run-linux`, although you will be responsible for finding a way to run it in the background, so that it doesn't close when the terminal does. One method might be to use: https://serverfault.com/questions/34750/is-it-possible-to-detach-a-process-from-its-terminal-or-i-should-have-used-s *Windows* Run `run.bat`, e.g. double-clicking on it. # How do I install plugins? For the default plugins like Markdown and Helloworld, you can find them in the Plugin Manager of your Control Panel. For ones which aren't included by default, you will need to drop them down in the `/extend/` directory. You will then need to recompile Gosora in order to link the plugin code with Gosora's code. For plugins not written in Go (e.g. JavaScript), they will automatically show up in your Control Panel ready to be installed, although we currently don't support these types of plugins at this time. There are also some experimental plugins in the `/experimental/` folder like plugin_sendmail which you may want to make use of, although there aren't any particular guarantees about whether they will continue to function or not. We're currently in the process of moving plugins from the `/` to the `/extend/` folder, if there is a piece of functionality that you would like to tap into, but which you cannot from that package, then feel free to poke me, otherwise you may need to drop it in `/` and name the package accordingly. # Images ![Shadow Theme](https://github.com/Azareal/Gosora/blob/master/images/shadow.png) ![Shadow Quick Topic](https://github.com/Azareal/Gosora/blob/master/images/quick-topics.png) ![Tempra Simple Theme](https://github.com/Azareal/Gosora/blob/master/images/tempra-simple.png) ![Tempra Simple Topic List](https://github.com/Azareal/Gosora/blob/master/images/topic-list.png) ![Tempra Simple Mobile](https://github.com/Azareal/Gosora/blob/master/images/tempra-simple-mobile-375px.png) ![Cosora Prototype WIP](https://github.com/Azareal/Gosora/blob/master/images/cosora-wip.png) More images in the /images/ folder. Beware though, some of them are *really* outdated. Also, keep in mind that a new theme is in the works. # Dependencies These are the libraries and pieces of software which Gosora relies on to function, an "ingredients" list so to speak. A few of these like Rez aren't currently in use, but are things we think we'll need in the very near future and want to have those things ready, so that we can quickly slot them in. * Go 1.11+ * MariaDB (or any other MySQL compatible database engine). We'll allow other database engines in the future. * github.com/go-sql-driver/mysql For interfacing with MariaDB. * golang.org/x/crypto/bcrypt For hashing passwords. * golang.org/x/crypto/argon2 For hashing passwords. * github.com/Azareal/gopsutil For pulling information on CPU and memory usage. I've temporarily forked this, as we were having stability issues with the latest build. * github.com/StackExchange/wmi Dependency for gopsutil on Windows. * golang.org/x/sys/windows Also a dependency for gopsutil on Windows. This isn't needed at the moment, as I've rolled things back to an older more stable build. * github.com/gorilla/websocket Needed for Gosora's Optional WebSockets Module. * github.com/robertkrimen/otto Needed for the upcoming JS plugin type. * gopkg.in/sourcemap.v1 Dependency for Otto. * github.com/lib/pq For interfacing with PostgreSQL. You will be able to pick this instead of MariaDB soon. * ithub.com/denisenkom/go-mssqldb For interfacing with MSSQL. You will be able to pick this instead of MSSQL soon. * github.com/bamiaux/rez An image resizer (e.g. for spitting out thumbnails) * github.com/esimov/caire The other image resizer, slower but may be useful for covering cases Rez does not. A third faster one we might point to at some point is probably Discord's Lilliput, however it requires a C Compiler and we don't want to add that as a dependency at this time. * github.com/fsnotify/fsnotify A library for watching events on the file system. * github.com/pkg/errors Some helpers to make it easier for us to track down bugs. * More items to come here, our dependencies are going through a lot of changes, and I'll be documenting those soon ;) # Bundled Plugins There are several plugins which are bundled with the software by default. These cover various common tasks which aren't common enough to clutter the core with or which have competing implementation methods (E.g. plugin_markdown vs plugin_bbcode for post mark-up). * Hey There / Skeleton / Hey There (JS Version) - Example plugins for helping you learn how to develop plugins. * BBCode - A plugin in early development for converting BBCode Tags into HTML. * Markdown - An extremely simple plugin for converting Markdown into HTML. * Social Groups - An extremely unstable WIP plugin which lets users create their own little discussion areas which they can administrate / moderate on their own. # Developers There are a few things you'll need to know before running the more developer oriented features like the tests or the benchmarks. The benchmarks are currently being rewritten as they're currently extremely serial which can lead to severe slow-downs when run on a home computer due to the benchmarks being run on the one core everything else is being run on (Browser, OS, etc.) and the tests not taking parallelism into account. ================================================ FILE: TODO.md ================================================ # TO-DO Oh my, you caught me right at the start of this project. There's nothing to see here yet, asides from the absolute basics. You might want to look again later! The various little features which somehow got stuck in the net. Don't worry, I'll get to them! More moderation features. E.g. Move, Approval Queue (Posts made by users in certain usergroups will need to be approved by a moderator before they're publically visible), etc. Add a simple anti-spam measure. I have quite a few ideas in mind, but it'll take a while to implement the more advanced ones, so I'd like to put off some of those to a later date and focus on the basics. E.g. CAPTCHAs, hidden fields, etc. Add more granular permissions management to the Forum Manager. Add a *better* plugin system. E.g. Allow for plugins written in Javascript and ones written in Go. Also, we need to add many, many, many more plugin hooks. I will need to ponder over implementing an even faster router. We don't need one immediately, although it would be nice if we could get one in the near future. It really depends. Ideally, it would be one which can easily integrate with the current structure without much work, although I'm not beyond making some alterations to faciliate it, assuming that we don't get too tightly bound to that specific router. Allow themes to define their own templates and to override core templates with their own. Add a friend system. Improve profile customisability. Implement all the common BBCode tags in plugin_bbcode Implement all the common Markdown codes in plugin_markdown Add more administration features. Add more features for improving user engagement. E.g. A like system. I have a few of these in mind, but I've been pre-occupied with implementing other features. Add a widget system. Add support for multi-factor authentication. Add support for secondary emails for users. Improve the shell scripts and possibly add support for Make? A make.go might be a good solution? ================================================ FILE: attachs/filler.txt ================================================ This file is here so that Git will include this folder in the repository. ================================================ FILE: backups/filler.txt ================================================ This file is here so that Git will include this folder in the repository. ================================================ FILE: build-linux ================================================ echo "Deleting artifacts from previous builds" rm -f template_*.go rm -f tmpl_*.go rm -f gen_*.go rm -f tmpl_client/template_* rm -f tmpl_client/tmpl_* rm -f ./Gosora rm -f ./common/gen_extend.go echo "Building the router generator" go build -ldflags="-s -w" -o RouterGen "./router_gen" echo "Running the router generator" ./RouterGen echo "Building the hook stub generator" go build -ldflags="-s -w" -o HookStubGen "./cmd/hook_stub_gen" echo "Running the hook stub generator" ./HookStubGen echo "Building the hook generator" go build -tags hookgen -ldflags="-s -w" -o HookGen "./cmd/hook_gen" echo "Running the hook generator" ./HookGen echo "Generating the JSON handlers" easyjson -pkg common echo "Building the query generator" go build -ldflags="-s -w" -o QueryGen "./cmd/query_gen" echo "Running the query generator" ./QueryGen echo "Building Gosora" go generate go build -ldflags="-s -w" -o Gosora echo "Building the installer" go build -ldflags="-s -w" -o Installer "./install" ================================================ FILE: build-linux-nowebsockets ================================================ echo "Deleting artifacts from previous builds" rm -f template_*.go rm -f tmpl_*.go rm -f gen_*.go rm -f tmpl_client/template_* rm -f tmpl_client/tmpl_* rm -f ./Gosora rm -f ./common/gen_extend.go echo "Building the router generator" go build -ldflags="-s -w" -o RouterGen "./router_gen" echo "Running the router generator" ./RouterGen echo "Building the hook stub generator" go build -ldflags="-s -w" -o HookStubGen "./cmd/hook_stub_gen" echo "Running the hook stub generator" ./HookStubGen echo "Building the hook generator" go build -tags hookgen -ldflags="-s -w" -o HookGen "./cmd/hook_gen" echo "Running the hook generator" ./HookGen echo "Generating the JSON handlers" easyjson -pkg common echo "Building the query generator" go build -ldflags="-s -w" -o QueryGen "./cmd/query_gen" echo "Running the query generator" ./QueryGen echo "Building Gosora" go generate go build -ldflags="-s -w" -o Gosora -tags no_ws echo "Building the installer" go build -ldflags="-s -w" -o Installer "./install" ================================================ FILE: build-nowebsockets.bat ================================================ @echo off rem TODO: Make these deletes a little less noisy del "template_*.go" del "tmpl_*.go" del "gen_*.go" del ".\tmpl_client\template_*" del ".\tmpl_client\tmpl_*" del ".\common\gen_extend.go" del "gosora.exe" echo Generating the dynamic code go generate if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Generating the JSON handlers easyjson -pkg common echo Building the executable go build -ldflags="-s -w" -o gosora.exe -tags no_ws if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Building the installer go build -ldflags="-s -w" "./cmd/install" if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Building the router generator go build -ldflags="-s -w" ./router_gen if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Building the hook stub generator go build -ldflags="-s -w" "./cmd/hook_stub_gen" if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Building the hook generator go build -tags hookgen -ldflags="-s -w" "./cmd/hook_gen" if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Building the query generator go build -ldflags="-s -w" "./cmd/query_gen" if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Gosora was successfully built pause ================================================ FILE: build.bat ================================================ @echo off rem TODO: Make these deletes a little less noisy del "template_*.go" del "tmpl_*.go" del "gen_*.go" del ".\tmpl_client\template_*" del ".\tmpl_client\tmpl_*" del ".\common\gen_extend.go" del "gosora.exe" echo Generating the dynamic code go generate if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Generating the JSON handlers easyjson -pkg common echo Building the executable go build -ldflags="-s -w" -o gosora.exe if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Building the installer go build -ldflags="-s -w" "./cmd/install" if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Building the router generator go build -ldflags="-s -w" ./router_gen if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Building the hook stub generator go build -ldflags="-s -w" "./cmd/hook_stub_gen" if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Building the hook generator go build -tags hookgen -ldflags="-s -w" "./cmd/hook_gen" if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Building the query generator go build -ldflags="-s -w" "./cmd/query_gen" if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo Gosora was successfully built pause ================================================ FILE: build_templates.bat ================================================ echo Building the templates gosora.exe -build-templates echo Rebuilding the executable go build -ldflags="-s -w" -o gosora.exe if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) pause ================================================ FILE: cmd/common_hook_gen/hookgen.go ================================================ package hookgen import ( "bytes" "log" "os" "text/template" ) type HookVars struct { Imports []string Hooks []Hook } type Hook struct { Name string Params string Params2 string Ret string Type string Any bool MultiHook bool Skip bool DefaultRet string Pure string } func AddHooks(add func(name, params, ret, htype string, multiHook, skip bool, defaultRet, pure string)) { vhookskip := func(name, params string) { add(name, params, "(bool,RouteError)", "VhookSkippable_", false, true, "false,nil", "") } vhookskip("simple_forum_check_pre_perms", "w http.ResponseWriter,r *http.Request,u *User,fid *int,h *HeaderLite") vhookskip("forum_check_pre_perms", "w http.ResponseWriter,r *http.Request,u *User,fid *int,h *Header") vhookskip("router_after_filters", "w http.ResponseWriter,r *http.Request,prefix string") vhookskip("router_pre_route", "w http.ResponseWriter,r *http.Request,u *User,prefix string") vhookskip("route_forum_list_start", "w http.ResponseWriter,r *http.Request,u *User,h *Header") vhookskip("route_topic_list_start", "w http.ResponseWriter,r *http.Request,u *User,h *Header") vhookskip("route_attach_start", "w http.ResponseWriter,r *http.Request,u *User,fname string") vhookskip("route_attach_post_get", "w http.ResponseWriter,r *http.Request,u *User,a *Attachment") vhooknoret := func(name, params string) { add(name, params, "", "Vhooks", false, false, "false,nil", "") } vhooknoret("router_end", "w http.ResponseWriter,r *http.Request,u *User,prefix string,extraData string") vhooknoret("topic_reply_row_assign", "r *ReplyUser") vhooknoret("counters_perf_tick_row", "low int64,high int64,avg int64") //forums_frow_assign //Hook(name string, data interface{}) interface{} /*hook := func(name, params, ret, pure string) { add(name,params,ret,"Hooks",true,false,ret,pure) }*/ hooknoret := func(name, params string) { add(name, params, "", "HooksNoRet", true, false, "", "") } hooknoret("forums_frow_assign", "f *Forum") hookskip := func(name, params string) { add(name, params, "(skip bool)", "HooksSkip", true, true, "", "") } //hookskip("forums_frow_assign","f *Forum") hookskip("topic_create_frow_assign", "f *Forum") hookss := func(name string) { add(name, "d string", "string", "Sshooks", true, false, "", "d") } hookss("topic_ogdesc_assign") } func Write(hookVars HookVars) { fileData := `// Code generated by Gosora's Hook Generator. DO NOT EDIT. /* This file was automatically generated by the software. Please don't edit it as your changes may be overwritten at any moment. */ package common import ({{range .Imports}} "{{.}}"{{end}} ) {{range .Hooks}} func H_{{.Name}}_hook(t *HookTable,{{.Params}}) {{.Ret}} { {{if .Any}} {{if .MultiHook}}for _, hook := range t.{{.Type}}["{{.Name}}"] { {{if .Skip}}if skip = hook({{.Params2}}); skip { break }{{else}}{{if .Pure}}{{.Pure}} = {{else if .Ret}}return {{end}}hook({{.Params2}}){{end}} }{{else}}hook := t.{{.Type}}["{{.Name}}"] if hook != nil { {{if .Ret}}return {{end}}hook({{.Params2}}) } {{end}}{{end}}{{if .Pure}} return {{.Pure}}{{else if .Ret}} return {{.DefaultRet}}{{end}} }{{end}} ` tmpl := template.Must(template.New("hooks").Parse(fileData)) var b bytes.Buffer if e := tmpl.Execute(&b, hookVars); e != nil { log.Fatal(e) } err := writeFile("./common/gen_extend.go", b.String()) if err != nil { log.Fatal(err) } } func writeFile(name, body string) error { f, e := os.Create(name) if e != nil { return e } if _, e = f.WriteString(body); e != nil { return e } if e = f.Sync(); e != nil { return e } return f.Close() } ================================================ FILE: cmd/elasticsearch/setup.go ================================================ // Work in progress package main import ( "context" "database/sql" "encoding/json" "errors" "log" "os" "strconv" c "github.com/Azareal/Gosora/common" "github.com/Azareal/Gosora/query_gen" "gopkg.in/olivere/elastic.v6" ) func main() { log.Print("Loading the configuration data") err := c.LoadConfig() if err != nil { log.Fatal(err) } log.Print("Processing configuration data") err = c.ProcessConfig() if err != nil { log.Fatal(err) } if c.DbConfig.Adapter != "mysql" && c.DbConfig.Adapter != "" { log.Fatal("Only MySQL is supported for upgrades right now, please wait for a newer build of the patcher") } err = prepMySQL() if err != nil { log.Fatal(err) } client, err := elastic.NewClient(elastic.SetErrorLog(log.New(os.Stdout, "ES ", log.LstdFlags))) if err != nil { log.Fatal(err) } _, _, err = client.Ping("http://127.0.0.1:9200").Do(context.Background()) if err != nil { log.Fatal(err) } err = setupIndices(client) if err != nil { log.Fatal(err) } err = setupData(client) if err != nil { log.Fatal(err) } } func prepMySQL() error { return qgen.Builder.Init("mysql", map[string]string{ "host": c.DbConfig.Host, "port": c.DbConfig.Port, "name": c.DbConfig.Dbname, "username": c.DbConfig.Username, "password": c.DbConfig.Password, "collation": "utf8mb4_general_ci", }) } type ESIndexBase struct { Mappings ESIndexMappings `json:"mappings"` } type ESIndexMappings struct { Doc ESIndexDoc `json:"_doc"` } type ESIndexDoc struct { Properties map[string]map[string]string `json:"properties"` } type ESDocMap map[string]map[string]string func (d ESDocMap) Add(column string, cType string) { d["column"] = map[string]string{"type": cType} } func setupIndices(client *elastic.Client) error { exists, err := client.IndexExists("topics").Do(context.Background()) if err != nil { return err } if exists { deleteIndex, err := client.DeleteIndex("topics").Do(context.Background()) if err != nil { return err } if !deleteIndex.Acknowledged { return errors.New("delete not acknowledged") } } docMap := make(ESDocMap) docMap.Add("tid", "integer") docMap.Add("title", "text") docMap.Add("content", "text") docMap.Add("createdBy", "integer") docMap.Add("ip", "ip") docMap.Add("suggest", "completion") indexBase := ESIndexBase{ESIndexMappings{ESIndexDoc{docMap}}} oBytes, err := json.Marshal(indexBase) if err != nil { return err } createIndex, err := client.CreateIndex("topics").Body(string(oBytes)).Do(context.Background()) if err != nil { return err } if !createIndex.Acknowledged { return errors.New("not acknowledged") } exists, err = client.IndexExists("replies").Do(context.Background()) if err != nil { return err } if exists { deleteIndex, err := client.DeleteIndex("replies").Do(context.Background()) if err != nil { return err } if !deleteIndex.Acknowledged { return errors.New("delete not acknowledged") } } docMap = make(ESDocMap) docMap.Add("rid", "integer") docMap.Add("tid", "integer") docMap.Add("content", "text") docMap.Add("createdBy", "integer") docMap.Add("ip", "ip") docMap.Add("suggest", "completion") indexBase = ESIndexBase{ESIndexMappings{ESIndexDoc{docMap}}} oBytes, err = json.Marshal(indexBase) if err != nil { return err } createIndex, err = client.CreateIndex("replies").Body(string(oBytes)).Do(context.Background()) if err != nil { return err } if !createIndex.Acknowledged { return errors.New("not acknowledged") } return nil } type ESTopic struct { ID int `json:"tid"` Title string `json:"title"` Content string `json:"content"` CreatedBy int `json:"createdBy"` IP string `json:"ip"` } type ESReply struct { ID int `json:"rid"` TID int `json:"tid"` Content string `json:"content"` CreatedBy int `json:"createdBy"` IP string `json:"ip"` } func setupData(client *elastic.Client) error { tcount := 4 errs := make(chan error) go func() { tin := make([]chan ESTopic, tcount) tf := func(tin chan ESTopic) { for { topic, more := <-tin if !more { break } _, err := client.Index().Index("topics").Type("_doc").Id(strconv.Itoa(topic.ID)).BodyJson(topic).Do(context.Background()) if err != nil { errs <- err } } } for i := 0; i < 4; i++ { go tf(tin[i]) } oi := 0 err := qgen.NewAcc().Select("topics").Cols("tid,title,content,createdBy,ip").Each(func(rows *sql.Rows) error { t := ESTopic{} err := rows.Scan(&t.ID, &t.Title, &t.Content, &t.CreatedBy, &t.IP) if err != nil { return err } tin[oi] <- t if oi < 3 { oi++ } return nil }) for i := 0; i < 4; i++ { close(tin[i]) } errs <- err }() go func() { rin := make([]chan ESReply, tcount) rf := func(rin chan ESReply) { for { reply, more := <-rin if !more { break } _, err := client.Index().Index("replies").Type("_doc").Id(strconv.Itoa(reply.ID)).BodyJson(reply).Do(context.Background()) if err != nil { errs <- err } } } for i := 0; i < 4; i++ { rf(rin[i]) } oi := 0 err := qgen.NewAcc().Select("replies").Cols("rid,tid,content,createdBy,ip").Each(func(rows *sql.Rows) error { r := ESReply{} err := rows.Scan(&r.ID, &r.TID, &r.Content, &r.CreatedBy, &r.IP) if err != nil { return err } rin[oi] <- r if oi < 3 { oi++ } return nil }) for i := 0; i < 4; i++ { close(rin[i]) } errs <- err }() fin := 0 for { err := <-errs if err == nil { fin++ if fin == 2 { return nil } } else { return err } } } ================================================ FILE: cmd/hook_gen/main.go ================================================ // +build hookgen package main // import "github.com/Azareal/Gosora/hook_gen" import ( "fmt" "log" "runtime/debug" "strings" h "github.com/Azareal/Gosora/cmd/common_hook_gen" c "github.com/Azareal/Gosora/common" _ "github.com/Azareal/Gosora/extend" ) // TODO: Make sure all the errors in this file propagate upwards properly func main() { // Capture panics instead of closing the window at a superhuman speed before the user can read the message on Windows defer func() { if r := recover(); r != nil { fmt.Println(r) debug.PrintStack() } }() hooks := make(map[string]int) for _, pl := range c.Plugins { if len(pl.Meta.Hooks) > 0 { for _, hook := range pl.Meta.Hooks { hooks[hook]++ } continue } if pl.Init != nil { if e := pl.Init(pl); e != nil { log.Print("early plugin init err: ", e) return } } if pl.Hooks != nil { log.Print("Hooks not nil for ", pl.UName) for hook, _ := range pl.Hooks { hooks[hook] += 1 } } } log.Printf("hooks: %+v\n", hooks) imports := []string{"net/http"} hookVars := h.HookVars{imports, nil} var params2sb strings.Builder add := func(name, params, ret, htype string, multiHook, skip bool, defaultRet, pure string) { first := true for _, param := range strings.Split(params, ",") { if !first { params2sb.WriteRune(',') } pspl := strings.Split(strings.ReplaceAll(strings.TrimSpace(param), " ", " "), " ") params2sb.WriteString(pspl[0]) first = false } hookVars.Hooks = append(hookVars.Hooks, h.Hook{name, params, params2sb.String(), ret, htype, hooks[name] > 0, multiHook, skip, defaultRet, pure}) params2sb.Reset() } h.AddHooks(add) h.Write(hookVars) log.Println("Successfully generated the hooks") } ================================================ FILE: cmd/hook_stub_gen/main.go ================================================ package main // import "github.com/Azareal/Gosora/hook_stub_gen" import ( "fmt" "log" "strings" "runtime/debug" h "github.com/Azareal/Gosora/cmd/common_hook_gen" ) // TODO: Make sure all the errors in this file propagate upwards properly func main() { // Capture panics instead of closing the window at a superhuman speed before the user can read the message on Windows defer func() { if r := recover(); r != nil { fmt.Println(r) debug.PrintStack() } }() imports := []string{"net/http"} hookVars := h.HookVars{imports,nil} add := func(name, params, ret, htype string, multiHook, skip bool, defaultRet, pure string) { var params2 string first := true for _, param := range strings.Split(params,",") { if !first { params2 += "," } pspl := strings.Split(strings.ReplaceAll(strings.TrimSpace(param)," "," ")," ") params2 += pspl[0] first = false } hookVars.Hooks = append(hookVars.Hooks, h.Hook{name, params, params2, ret, htype, true, multiHook, skip, defaultRet,pure}) } h.AddHooks(add) h.Write(hookVars) log.Println("Successfully generated the hooks") } ================================================ FILE: cmd/install/install.go ================================================ /* * * Gosora Installer * Copyright Azareal 2017 - 2019 * */ package main import ( "bufio" "errors" "fmt" "os" "runtime/debug" "strconv" "strings" "github.com/Azareal/Gosora/install" ) var scanner *bufio.Scanner var siteShortName string var siteName string var siteURL string var serverPort string var defaultAdapter = "mysql" var defaultHost = "localhost" var defaultUsername = "root" var defaultDbname = "gosora" var defaultSiteShortName = "SN" var defaultSiteName = "Site Name" var defaultsiteURL = "localhost" var defaultServerPort = "80" // 8080's a good one, if you're testing and don't want it to clash with port 80 func main() { // Capture panics instead of closing the window at a superhuman speed before the user can read the message on Windows defer func() { if r := recover(); r != nil { fmt.Println(r) debug.PrintStack() pressAnyKey() return } }() scanner = bufio.NewScanner(os.Stdin) fmt.Println("Welcome to Gosora's Installer") fmt.Println("We're going to take you through a few steps to help you get started :)") adap, ok := handleDatabaseDetails() if !ok { err := scanner.Err() if err != nil { fmt.Println(err) } else { err = errors.New("Something went wrong!") } abortError(err) return } if !getSiteDetails() { err := scanner.Err() if err != nil { fmt.Println(err) } else { err = errors.New("Something went wrong!") } abortError(err) return } err := adap.InitDatabase() if err != nil { abortError(err) return } err = adap.TableDefs() if err != nil { abortError(err) return } err = adap.CreateAdmin() if err != nil { abortError(err) return } err = adap.InitialData() if err != nil { abortError(err) return } configContents := []byte(`{ "Site": { "ShortName":"` + siteShortName + `", "Name":"` + siteName + `", "URL":"` + siteURL + `", "Port":"` + serverPort + `", "EnableSsl":false, "EnableEmails":false, "HasProxy":false, "Language": "english" }, "Config": { "SslPrivkey": "", "SslFullchain": "", "SMTPServer": "", "SMTPUsername": "", "SMTPPassword": "", "SMTPPort": "25", "MaxRequestSizeStr":"5MB", "UserCache":"static", "TopicCache":"static", "ReplyCache":"static", "UserCacheCapacity":180, "TopicCacheCapacity":400, "ReplyCacheCapacity":20, "DefaultPath":"/topics/", "DefaultGroup":3, "ActivationGroup":5, "StaffCSS":"staff_post", "DefaultForum":2, "MinifyTemplates":true, "BuildSlugs":true, "ServerCount":1, "Noavatar":"https://api.adorable.io/avatars/{width}/{id}.png", "ItemsPerPage":25 }, "Database": { "Adapter": "` + adap.Name() + `", "Host": "` + adap.DBHost() + `", "Username": "` + adap.DBUsername() + `", "Password": "` + adap.DBPassword() + `", "Dbname": "` + adap.DBName() + `", "Port": "` + adap.DBPort() + `", "TestAdapter": "` + adap.Name() + `", "TestHost": "", "TestUsername": "", "TestPassword": "", "TestDbname": "", "TestPort": "" }, "Dev": { "DebugMode":true, "SuperDebug":false } }`) fmt.Println("Opening the configuration file") configFile, err := os.Create("./config/config.json") if err != nil { abortError(err) return } fmt.Println("Writing to the configuration file...") _, err = configFile.Write(configContents) if err != nil { abortError(err) return } configFile.Sync() configFile.Close() fmt.Println("Finished writing to the configuration file") fmt.Println("Yay, you have successfully installed Gosora!") fmt.Println("Your name is Admin and you can login with the password 'password'. Don't forget to change it! Seriously. It's really insecure.") pressAnyKey() } func abortError(err error) { fmt.Println(err) fmt.Println("Aborting installation...") pressAnyKey() } func handleDatabaseDetails() (adap install.InstallAdapter, ok bool) { var dbAdapter string var dbHost string var dbUsername string var dbPassword string var dbName string // TODO: Let the admin set the database port? //var dbPort string for { fmt.Println("Which database adapter do you wish to use? mysql or mssql? Default: mysql") if !scanner.Scan() { return nil, false } dbAdapter := strings.TrimSpace(scanner.Text()) if dbAdapter == "" { dbAdapter = defaultAdapter } adap, ok = install.Lookup(dbAdapter) if ok { break } fmt.Println("That adapter doesn't exist") } fmt.Println("Set database adapter to " + dbAdapter) fmt.Println("Database Host? Default: " + defaultHost) if !scanner.Scan() { return nil, false } dbHost = scanner.Text() if dbHost == "" { dbHost = defaultHost } fmt.Println("Set database host to " + dbHost) fmt.Println("Database Username? Default: " + defaultUsername) if !scanner.Scan() { return nil, false } dbUsername = scanner.Text() if dbUsername == "" { dbUsername = defaultUsername } fmt.Println("Set database username to " + dbUsername) fmt.Println("Database Password? Default: ''") if !scanner.Scan() { return nil, false } dbPassword = scanner.Text() if len(dbPassword) == 0 { fmt.Println("You didn't set a password for this user. This won't block the installation process, but it might create security issues in the future.") fmt.Println("") } else { fmt.Println("Set password to " + obfuscatePassword(dbPassword)) } fmt.Println("Database Name? Pick a name you like or one provided to you. Default: " + defaultDbname) if !scanner.Scan() { return nil, false } dbName = scanner.Text() if dbName == "" { dbName = defaultDbname } fmt.Println("Set database name to " + dbName) adap.SetConfig(dbHost, dbUsername, dbPassword, dbName, adap.DefaultPort()) return adap, true } func getSiteDetails() bool { fmt.Println("Okay. We also need to know some actual information about your site!") fmt.Println("What's your site's name? Default: " + defaultSiteName) if !scanner.Scan() { return false } siteName = scanner.Text() if siteName == "" { siteName = defaultSiteName } fmt.Println("Set the site name to " + siteName) // ? - We could compute this based on the first letter of each word in the site's name, if it's name spans multiple words. I'm not sure how to do this for single word names. fmt.Println("Can we have a short abbreviation for your site? Default: " + defaultSiteShortName) if !scanner.Scan() { return false } siteShortName = scanner.Text() if siteShortName == "" { siteShortName = defaultSiteShortName } fmt.Println("Set the short name to " + siteShortName) fmt.Println("What's your site's url? Default: " + defaultsiteURL) if !scanner.Scan() { return false } siteURL = scanner.Text() if siteURL == "" { siteURL = defaultsiteURL } fmt.Println("Set the site url to " + siteURL) fmt.Println("What port do you want the server to listen on? If you don't know what this means, you should probably leave it on the default. Default: " + defaultServerPort) if !scanner.Scan() { return false } serverPort = scanner.Text() if serverPort == "" { serverPort = defaultServerPort } _, err := strconv.Atoi(serverPort) if err != nil { fmt.Println("That's not a valid number!") return false } fmt.Println("Set the server port to " + serverPort) return true } func obfuscatePassword(password string) (out string) { for i := 0; i < len(password); i++ { out += "*" } return out } func pressAnyKey() { //fmt.Println("Press any key to exit...") fmt.Println("Please press enter to exit...") for scanner.Scan() { _ = scanner.Text() return } } ================================================ FILE: cmd/query_gen/build.bat ================================================ @echo off echo Building the query generator go build -ldflags="-s -w" if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo The query generator was successfully built pause ================================================ FILE: cmd/query_gen/main.go ================================================ /* WIP Under Construction */ package main // import "github.com/Azareal/Gosora/query_gen" import ( "encoding/json" "fmt" "log" "os" "runtime/debug" "strconv" "strings" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" ) // TODO: Make sure all the errors in this file propagate upwards properly func main() { // Capture panics instead of closing the window at a superhuman speed before the user can read the message on Windows defer func() { if r := recover(); r != nil { fmt.Println(r) debug.PrintStack() return } }() log.Println("Running the query generator") for _, a := range qgen.Registry { log.Printf("Building the queries for the %s adapter", a.GetName()) qgen.Install.SetAdapterInstance(a) qgen.Install.AddPlugins(NewPrimaryKeySpitter()) // TODO: Do we really need to fill the spitter for every adapter? e := writeStatements(a) if e != nil { log.Print(e) } e = qgen.Install.Write() if e != nil { log.Print(e) } e = a.Write() if e != nil { log.Print(e) } } } // nolint func writeStatements(a qgen.Adapter) (err error) { e := func(f func(qgen.Adapter) error) { if err != nil { return } err = f(a) } e(createTables) e(seedTables) e(writeSelects) e(writeLeftJoins) e(writeInnerJoins) e(writeInserts) e(writeUpdates) e(writeDeletes) e(writeSimpleCounts) e(writeInsertSelects) e(writeInsertLeftJoins) e(writeInsertInnerJoins) return err } type si = map[string]interface{} type tK = tblKey func seedTables(a qgen.Adapter) error { qgen.Install.AddIndex("topics", "parentID", "parentID") qgen.Install.AddIndex("replies", "tid", "tid") qgen.Install.AddIndex("polls", "parentID", "parentID") qgen.Install.AddIndex("likes", "targetItem", "targetItem") qgen.Install.AddIndex("emails", "uid", "uid") qgen.Install.AddIndex("attachments", "originID", "originID") qgen.Install.AddIndex("attachments", "path", "path") qgen.Install.AddIndex("activity_stream_matches", "watcher", "watcher") // TODO: Remove these keys to save space when Elasticsearch is active? //qgen.Install.AddKey("topics", "title", tK{"title", "fulltext", "", false}) //qgen.Install.AddKey("topics", "content", tK{"content", "fulltext", "", false}) //qgen.Install.AddKey("topics", "title,content", tK{"title,content", "fulltext", "", false}) //qgen.Install.AddKey("replies", "content", tK{"content", "fulltext", "", false}) insert := func(tbl, cols, vals string) { qgen.Install.SimpleInsert(tbl, cols, vals) } insert("sync", "last_update", "UTC_TIMESTAMP()") addSetting := func(name, content, stype string, constraints ...string) { if strings.Contains(name, "'") { panic("name contains '") } if strings.Contains(stype, "'") { panic("stype contains '") } // TODO: Add more field validators cols := "name,content,type" if len(constraints) > 0 { cols += ",constraints" } q := func(s string) string { return "'" + s + "'" } c := func() string { if len(constraints) == 0 { return "" } return "," + q(constraints[0]) } insert("settings", cols, q(name)+","+q(content)+","+q(stype)+c()) } addSetting("activation_type", "1", "list", "1-3") addSetting("bigpost_min_words", "250", "int") addSetting("megapost_min_words", "1000", "int") addSetting("meta_desc", "", "html-attribute") addSetting("rapid_loading", "1", "bool") addSetting("google_site_verify", "", "html-attribute") addSetting("avatar_visibility", "0", "list", "0-1") insert("themes", "uname, default", "'cosora',1") insert("emails", "email, uid, validated", "'admin@localhost',1,1") // ? - Use a different default email or let the admin input it during installation? /* The Permissions: Global Permissions: BanUsers ActivateUsers EditUser EditUserEmail EditUserPassword EditUserGroup EditUserGroupSuperMod EditUserGroupAdmin EditGroup EditGroupLocalPerms EditGroupGlobalPerms EditGroupSuperMod EditGroupAdmin ManageForums EditSettings ManageThemes ManagePlugins ViewAdminLogs ViewIPs Non-staff Global Permissions: UploadFiles UploadAvatars UseConvos UseConvosOnlyWithMod CreateProfileReply AutoEmbed AutoLink // CreateConvo ? // CreateConvoReply ? Forum Permissions: ViewTopic LikeItem CreateTopic EditTopic DeleteTopic CreateReply EditReply DeleteReply PinTopic CloseTopic MoveTopic */ p := func(perms *c.Perms) string { jBytes, err := json.Marshal(perms) if err != nil { panic(err) } return string(jBytes) } addGroup := func(name string, perms c.Perms, mod, admin, banned bool, tag string) { mi, ai, bi := "0", "0", "0" if mod { mi = "1" } if admin { ai = "1" } if banned { bi = "1" } insert("users_groups", "name, permissions, plugin_perms, is_mod, is_admin, is_banned, tag", `'`+name+`','`+p(&perms)+`','{}',`+mi+`,`+ai+`,`+bi+`,"`+tag+`"`) } perms := c.AllPerms perms.EditUserGroupAdmin = false perms.EditGroupAdmin = false addGroup("Administrator", perms, true, true, false, "Admin") perms = c.Perms{BanUsers: true, ActivateUsers: true, EditUser: true, EditUserEmail: false, EditUserGroup: true, ViewIPs: true, UploadFiles: true, UploadAvatars: true, UseConvos: true, UseConvosOnlyWithMod: true, CreateProfileReply: true, AutoEmbed: true, AutoLink: true, ViewTopic: true, LikeItem: true, CreateTopic: true, EditTopic: true, DeleteTopic: true, CreateReply: true, EditReply: true, DeleteReply: true, PinTopic: true, CloseTopic: true, MoveTopic: true} addGroup("Moderator", perms, true, false, false, "Mod") perms = c.Perms{UploadFiles: true, UploadAvatars: true, UseConvos: true, UseConvosOnlyWithMod: true, CreateProfileReply: true, AutoEmbed: true, AutoLink: true, ViewTopic: true, LikeItem: true, CreateTopic: true, CreateReply: true} addGroup("Member", perms, false, false, false, "") perms = c.Perms{ViewTopic: true} addGroup("Banned", perms, false, false, true, "") addGroup("Awaiting Activation", c.Perms{ViewTopic: true, UseConvosOnlyWithMod: true}, false, false, false, "") addGroup("Not Loggedin", perms, false, false, false, "Guest") // // TODO: Stop processFields() from stripping the spaces in the descriptions in the next commit insert("forums", "name, active, desc, tmpl", "'Reports',0,'All the reports go here',''") insert("forums", "name, lastTopicID, lastReplyerID, desc, tmpl", "'General',1,1,'A place for general discussions which don't fit elsewhere',''") // /*var addForumPerm = func(gid, fid int, permStr string) { insert("forums_permissions", "gid, fid, permissions", strconv.Itoa(gid)+`,`+strconv.Itoa(fid)+`,'`+permStr+`'`) }*/ insert("forums_permissions", "gid, fid, permissions", `1,1,'{"ViewTopic":true,"CreateReply":true,"CreateTopic":true,"PinTopic":true,"CloseTopic":true}'`) insert("forums_permissions", "gid, fid, permissions", `2,1,'{"ViewTopic":true,"CreateReply":true,"CloseTopic":true}'`) insert("forums_permissions", "gid, fid, permissions", "3,1,'{}'") insert("forums_permissions", "gid, fid, permissions", "4,1,'{}'") insert("forums_permissions", "gid, fid, permissions", "5,1,'{}'") insert("forums_permissions", "gid, fid, permissions", "6,1,'{}'") // insert("forums_permissions", "gid, fid, permissions", `1,2,'{"ViewTopic":true,"CreateReply":true,"CreateTopic":true,"LikeItem":true,"EditTopic":true,"DeleteTopic":true,"EditReply":true,"DeleteReply":true,"PinTopic":true,"CloseTopic":true,"MoveTopic":true}'`) insert("forums_permissions", "gid, fid, permissions", `2,2,'{"ViewTopic":true,"CreateReply":true,"CreateTopic":true,"LikeItem":true,"EditTopic":true,"DeleteTopic":true,"EditReply":true,"DeleteReply":true,"PinTopic":true,"CloseTopic":true,"MoveTopic":true}'`) insert("forums_permissions", "gid, fid, permissions", `3,2,'{"ViewTopic":true,"CreateReply":true,"CreateTopic":true,"LikeItem":true}'`) insert("forums_permissions", "gid, fid, permissions", `4,2,'{"ViewTopic":true}'`) insert("forums_permissions", "gid, fid, permissions", `5,2,'{"ViewTopic":true}'`) insert("forums_permissions", "gid, fid, permissions", `6,2,'{"ViewTopic":true}'`) // insert("topics", "title, content, parsed_content, createdAt, lastReplyAt, lastReplyBy, createdBy, parentID, ip", "'Test Topic','A topic automatically generated by the software.','A topic automatically generated by the software.',UTC_TIMESTAMP(),UTC_TIMESTAMP(),1,1,2,''") insert("replies", "tid, content, parsed_content, createdAt, createdBy, lastUpdated, lastEdit, lastEditBy, ip", "1,'A reply!','A reply!',UTC_TIMESTAMP(),1,UTC_TIMESTAMP(),0,0,''") insert("menus", "", "") // Go maps have a random iteration order, so we have to do this, otherwise the schema files will become unstable and harder to audit order := 0 mOrder := "mid, name, htmlID, cssClass, position, path, aria, tooltip, guestOnly, memberOnly, staffOnly, adminOnly" addMenuItem := func(data map[string]interface{}) { if data["mid"] == nil { data["mid"] = 1 } if data["position"] == nil { data["position"] = "left" } cols, values := qgen.InterfaceMapToInsertStrings(data, mOrder) insert("menu_items", cols+", order", values+","+strconv.Itoa(order)) order++ } addMenuItem(si{"name": "{lang.menu_forums}", "htmlID": "menu_forums", "path": "/forums/", "aria": "{lang.menu_forums_aria}", "tooltip": "{lang.menu_forums_tooltip}"}) addMenuItem(si{"name": "{lang.menu_topics}", "htmlID": "menu_topics", "cssClass": "menu_topics", "path": "/topics/", "aria": "{lang.menu_topics_aria}", "tooltip": "{lang.menu_topics_tooltip}"}) addMenuItem(si{"htmlID": "general_alerts", "cssClass": "menu_alerts", "position": "right", "tmplName": "menu_alerts"}) addMenuItem(si{"name": "{lang.menu_account}", "cssClass": "menu_account", "path": "/user/edit/", "aria": "{lang.menu_account_aria}", "tooltip": "{lang.menu_account_tooltip}", "memberOnly": true}) addMenuItem(si{"name": "{lang.menu_profile}", "cssClass": "menu_profile", "path": "{me.Link}", "aria": "{lang.menu_profile_aria}", "tooltip": "{lang.menu_profile_tooltip}", "memberOnly": true}) addMenuItem(si{"name": "{lang.menu_panel}", "cssClass": "menu_panel menu_account", "path": "/panel/", "aria": "{lang.menu_panel_aria}", "tooltip": "{lang.menu_panel_tooltip}", "memberOnly": true, "staffOnly": true}) addMenuItem(si{"name": "{lang.menu_logout}", "cssClass": "menu_logout", "path": "/accounts/logout/?s={me.Session}", "aria": "{lang.menu_logout_aria}", "tooltip": "{lang.menu_logout_tooltip}", "memberOnly": true}) addMenuItem(si{"name": "{lang.menu_register}", "cssClass": "menu_register", "path": "/accounts/create/", "aria": "{lang.menu_register_aria}", "tooltip": "{lang.menu_register_tooltip}", "guestOnly": true}) addMenuItem(si{"name": "{lang.menu_login}", "cssClass": "menu_login", "path": "/accounts/login/", "aria": "{lang.menu_login_aria}", "tooltip": "{lang.menu_login_tooltip}", "guestOnly": true}) /*var fSet []string for _, table := range tables { fSet = append(fSet, "'"+table+"'") } qgen.Install.SimpleBulkInsert("tables", "name", fSet)*/ return nil } // ? - What is this for? /*func copyInsertMap(in map[string]interface{}) (out map[string]interface{}) { out = make(map[string]interface{}) for col, value := range in { out[col] = value } return out }*/ type LitStr string func writeSelects(a qgen.Adapter) error { b := a.Builder() // Looking for getTopic? Your statement is in another castle //b.Select("isPluginInstalled").Table("plugins").Columns("installed").Where("uname = ?").Parse() b.Select("forumEntryExists").Table("forums").Columns("fid").Where("name = ''").Orderby("fid ASC").Limit("0,1").Parse() b.Select("groupEntryExists").Table("users_groups").Columns("gid").Where("name = ''").Orderby("gid ASC").Limit("0,1").Parse() return nil } func writeLeftJoins(a qgen.Adapter) error { a.SimpleLeftJoin("getForumTopics", "topics", "users", "topics.tid, topics.title, topics.content, topics.createdBy, topics.is_closed, topics.sticky, topics.createdAt, topics.lastReplyAt, topics.parentID, users.name, users.avatar", "topics.createdBy = users.uid", "topics.parentID = ?", "topics.sticky DESC, topics.lastReplyAt DESC, topics.createdBy desc", "") return nil } func writeInnerJoins(a qgen.Adapter) (err error) { return nil } func writeInserts(a qgen.Adapter) error { b := a.Builder() b.Insert("addForumPermsToForum").Table("forums_permissions").Columns("gid,fid,preset,permissions").Fields("?,?,?,?").Parse() return nil } func writeUpdates(a qgen.Adapter) error { b := a.Builder() b.Update("updateEmail").Table("emails").Set("email = ?, uid = ?, validated = ?, token = ?").Where("email = ?").Parse() b.Update("setTempGroup").Table("users").Set("temp_group = ?").Where("uid = ?").Parse() b.Update("bumpSync").Table("sync").Set("last_update = UTC_TIMESTAMP()").Parse() return nil } func writeDeletes(a qgen.Adapter) error { b := a.Builder() //b.Delete("deleteForumPermsByForum").Table("forums_permissions").Where("fid=?").Parse() b.Delete("deleteActivityStreamMatch").Table("activity_stream_matches").Where("watcher=? AND asid=?").Parse() //b.Delete("deleteActivityStreamMatchesByWatcher").Table("activity_stream_matches").Where("watcher=?").Parse() return nil } func writeSimpleCounts(a qgen.Adapter) error { return nil } func writeInsertSelects(a qgen.Adapter) error { /*a.SimpleInsertSelect("addForumPermsToForumAdmins", qgen.DB_Insert{"forums_permissions", "gid, fid, preset, permissions", ""}, qgen.DB_Select{"users_groups", "gid, ? AS fid, ? AS preset, ? AS permissions", "is_admin = 1", "", ""}, )*/ /*a.SimpleInsertSelect("addForumPermsToForumStaff", qgen.DB_Insert{"forums_permissions", "gid, fid, preset, permissions", ""}, qgen.DB_Select{"users_groups", "gid, ? AS fid, ? AS preset, ? AS permissions", "is_admin = 0 AND is_mod = 1", "", ""}, )*/ /*a.SimpleInsertSelect("addForumPermsToForumMembers", qgen.DB_Insert{"forums_permissions", "gid, fid, preset, permissions", ""}, qgen.DB_Select{"users_groups", "gid, ? AS fid, ? AS preset, ? AS permissions", "is_admin = 0 AND is_mod = 0 AND is_banned = 0", "", ""}, )*/ return nil } // nolint func writeInsertLeftJoins(a qgen.Adapter) error { return nil } func writeInsertInnerJoins(a qgen.Adapter) error { return nil } func writeFile(name, content string) (err error) { f, err := os.Create(name) if err != nil { return err } _, err = f.WriteString(content) if err != nil { return err } err = f.Sync() if err != nil { return err } return f.Close() } ================================================ FILE: cmd/query_gen/run.bat ================================================ @echo off echo Building the query generator go build -ldflags="-s -w" if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo The query generator was successfully built query_gen.exe pause ================================================ FILE: cmd/query_gen/spitter.go ================================================ package main import "strings" import "github.com/Azareal/Gosora/query_gen" type PrimaryKeySpitter struct { keys map[string]string } func NewPrimaryKeySpitter() *PrimaryKeySpitter { return &PrimaryKeySpitter{make(map[string]string)} } func (spit *PrimaryKeySpitter) Hook(name string, args ...interface{}) error { if name == "CreateTableStart" { var found string var table = args[0].(*qgen.DBInstallTable) for _, key := range table.Keys { if key.Type == "primary" { expl := strings.Split(key.Columns, ",") if len(expl) > 1 { continue } found = key.Columns } if found != "" { table := table.Name spit.keys[table] = found } } } return nil } func (spit *PrimaryKeySpitter) Write() error { out := `// Generated by Gosora's Query Generator. DO NOT EDIT. package main var dbTablePrimaryKeys = map[string]string{ ` for table, key := range spit.keys { out += "\t\"" + table + "\":\"" + key + "\",\n" } return writeFile("./gen_tables.go", out+"}\n") } ================================================ FILE: cmd/query_gen/tables.go ================================================ package main import qgen "github.com/Azareal/Gosora/query_gen" var mysqlPre = "utf8mb4" var mysqlCol = "utf8mb4_general_ci" var tables []string type tblColumn = qgen.DBTableColumn type tC = tblColumn type tblKey = qgen.DBTableKey func createTables(a qgen.Adapter) error { tables = nil f := func(table, charset, collation string, cols []tC, keys []tblKey) error { tables = append(tables, table) return qgen.Install.CreateTable(table, charset, collation, cols, keys) } return createTables2(a, f) } func createTables2(a qgen.Adapter, f func(table, charset, collation string, columns []tC, keys []tblKey) error) (err error) { createTable := func(table, charset, collation string, cols []tC, keys []tblKey) { if err != nil { return } err = f(table, charset, collation, cols, keys) } bcol := func(col string, val bool) qgen.DBTableColumn { if val { return tC{col, "boolean", 0, false, false, "1"} } return tC{col, "boolean", 0, false, false, "0"} } ccol := func(col string, size int, sdefault string) qgen.DBTableColumn { return tC{col, "varchar", size, false, false, sdefault} } text := func(params ...string) qgen.DBTableColumn { if len(params) == 0 { return tC{"", "text", 0, false, false, ""} } col, sdefault := params[0], "" if len(params) > 1 { sdefault = params[1] if sdefault == "" { sdefault = "''" } } return tC{col, "text", 0, false, false, sdefault} } createdAt := func(coll ...string) qgen.DBTableColumn { var col string if len(coll) > 0 { col = coll[0] } if col == "" { col = "createdAt" } return tC{col, "createdAt", 0, false, false, ""} } createTable("users", mysqlPre, mysqlCol, []tC{ {"uid", "int", 0, false, true, ""}, ccol("name", 100, ""), ccol("password", 100, ""), ccol("salt", 80, "''"), {"group", "int", 0, false, false, ""}, // TODO: Make this a foreign key bcol("active", false), bcol("is_super_admin", false), createdAt(), {"lastActiveAt", "datetime", 0, false, false, ""}, ccol("session", 200, "''"), //ccol("authToken", 200, "''"), ccol("last_ip", 200, "''"), {"profile_comments", "int", 0, false, false, "0"}, {"who_can_convo", "int", 0, false, false, "0"}, {"enable_embeds", "int", 0, false, false, "-1"}, ccol("email", 200, "''"), ccol("avatar", 100, "''"), text("message"), // TODO: Drop these columns? ccol("url_prefix", 20, "''"), ccol("url_name", 100, "''"), //text("pub_key"), {"level", "smallint", 0, false, false, "0"}, {"score", "int", 0, false, false, "0"}, {"posts", "int", 0, false, false, "0"}, {"bigposts", "int", 0, false, false, "0"}, {"megaposts", "int", 0, false, false, "0"}, {"topics", "int", 0, false, false, "0"}, {"liked", "int", 0, false, false, "0"}, // These two are to bound liked queries with little bits of information we know about the user to reduce the server load {"oldestItemLikedCreatedAt", "datetime", 0, false, false, ""}, // For internal use only, semantics may change {"lastLiked", "datetime", 0, false, false, ""}, // For internal use only, semantics may change //{"penalty_count","int",0,false,false,"0"}, {"temp_group", "int", 0, false, false, "0"}, // For temporary groups, set this to zero when a temporary group isn't in effect }, []tK{ {"uid", "primary", "", false}, {"name", "unique", "", false}, }, ) createTable("users_groups", mysqlPre, mysqlCol, []tC{ {"gid", "int", 0, false, true, ""}, ccol("name", 100, ""), text("permissions"), text("plugin_perms"), bcol("is_mod", false), bcol("is_admin", false), bcol("is_banned", false), {"user_count", "int", 0, false, false, "0"}, // TODO: Implement this ccol("tag", 50, "''"), }, []tK{ {"gid", "primary", "", false}, }, ) createTable("users_groups_promotions", mysqlPre, mysqlCol, []tC{ {"pid", "int", 0, false, true, ""}, {"from_gid", "int", 0, false, false, ""}, {"to_gid", "int", 0, false, false, ""}, bcol("two_way", false), // If a user no longer meets the requirements for this promotion then they will be demoted if this flag is set // Requirements {"level", "int", 0, false, false, ""}, {"posts", "int", 0, false, false, "0"}, {"minTime", "int", 0, false, false, ""}, // How long someone needs to have been in their current group before being promoted {"registeredFor", "int", 0, false, false, "0"}, // minutes }, []tK{ {"pid", "primary", "", false}, }, ) /* createTable("users_groups_promotions_scheduled","","", []tC{ {"prid","int",0,false,false,""}, {"uid","int",0,false,false,""}, {"runAt","datetime",0,false,false,""}, }, []tK{ // TODO: Test to see that the compound primary key works {"prid,uid", "primary", "", false}, }, ) */ createTable("users_2fa_keys", mysqlPre, mysqlCol, []tC{ {"uid", "int", 0, false, false, ""}, ccol("secret", 100, ""), ccol("scratch1", 50, ""), ccol("scratch2", 50, ""), ccol("scratch3", 50, ""), ccol("scratch4", 50, ""), ccol("scratch5", 50, ""), ccol("scratch6", 50, ""), ccol("scratch7", 50, ""), ccol("scratch8", 50, ""), {"createdAt", "createdAt", 0, false, false, ""}, }, []tK{ {"uid", "primary", "", false}, }, ) // What should we do about global penalties? Put them on the users table for speed? Or keep them here? // Should we add IP Penalties? No, that's a stupid idea, just implement IP Bans properly. What about shadowbans? // TODO: Perm overrides // TODO: Add a mod-queue and other basic auto-mod features. This is needed for awaiting activation and the mod_queue penalty flag // TODO: Add a penalty type where a user is stopped from creating plugin_guilds social groups // TODO: Shadow bans. We will probably have a CanShadowBan permission for this, as we *really* don't want people using this lightly. /*createTable("users_penalties","","", []tC{ {"uid","int",0,false,false,""}, {"element_id","int",0,false,false,""}, ccol("element_type",50,""), //forum, profile?, and social_group. Leave blank for global. text("overrides","{}"), bcol("mod_queue",false), bcol("shadow_ban",false), bcol("no_avatar",false), // Coming Soon. Should this be a perm override instead? // Do we *really* need rate-limit penalty types? Are we going to be allowing bots or something? //{"posts_per_hour","int",0,false,false,"0"}, //{"topics_per_hour","int",0,false,false,"0"}, //{"posts_count","int",0,false,false,"0"}, //{"topic_count","int",0,false,false,"0"}, //{"last_hour","int",0,false,false,"0"}, // UNIX Time, as we don't need to do anything too fancy here. When an hour has elapsed since that time, reset the hourly penalty counters. {"issued_by","int",0,false,false,""}, createdAt("issued_at"), {"expires_at","datetime",0,false,false,""}, }, nil, )*/ createTable("users_groups_scheduler", "", "", []tC{ {"uid", "int", 0, false, false, ""}, {"set_group", "int", 0, false, false, ""}, {"issued_by", "int", 0, false, false, ""}, createdAt("issued_at"), {"revert_at", "datetime", 0, false, false, ""}, {"temporary", "boolean", 0, false, false, ""}, // special case for permanent bans to do the necessary bookkeeping, might be removed in the future }, []tK{ {"uid", "primary", "", false}, }, ) // TODO: Can we use a piece of software dedicated to persistent queues for this rather than relying on the database for it? createTable("users_avatar_queue", "", "", []tC{ {"uid", "int", 0, false, false, ""}, // TODO: Make this a foreign key }, []tK{ {"uid", "primary", "", false}, }, ) // TODO: Should we add a users prefix to this table to fit the "unofficial convention"? // TODO: Add an autoincrement key? createTable("emails", "", "", []tC{ ccol("email", 200, ""), {"uid", "int", 0, false, false, ""}, // TODO: Make this a foreign key bcol("validated", false), ccol("token", 200, "''"), }, nil, ) // TODO: Allow for patterns in domains, if the bots try to shake things up there? /* createTable("email_domain_blacklist", "", "", []tC{ ccol("domain", 200, ""), bcol("gtld", false), }, []tK{ {"domain", "primary"}, }, ) */ // TODO: Implement password resets createTable("password_resets", "", "", []tC{ ccol("email", 200, ""), {"uid", "int", 0, false, false, ""}, // TODO: Make this a foreign key ccol("validated", 200, ""), // Token given once the one-use token is consumed, used to prevent multiple people consuming the same one-use token ccol("token", 200, ""), createdAt(), }, nil, ) createTable("forums", mysqlPre, mysqlCol, []tC{ {"fid", "int", 0, false, true, ""}, ccol("name", 100, ""), ccol("desc", 200, ""), ccol("tmpl", 200, "''"), bcol("active", true), {"order", "int", 0, false, false, "0"}, {"topicCount", "int", 0, false, false, "0"}, ccol("preset", 100, "''"), {"parentID", "int", 0, false, false, "0"}, ccol("parentType", 50, "''"), {"lastTopicID", "int", 0, false, false, "0"}, {"lastReplyerID", "int", 0, false, false, "0"}, }, []tK{ {"fid", "primary", "", false}, }, ) createTable("forums_permissions", "", "", []tC{ {"fid", "int", 0, false, false, ""}, {"gid", "int", 0, false, false, ""}, ccol("preset", 100, "''"), text("permissions", "{}"), }, []tK{ // TODO: Test to see that the compound primary key works {"fid,gid", "primary", "", false}, }, ) createTable("topics", mysqlPre, mysqlCol, []tC{ {"tid", "int", 0, false, true, ""}, ccol("title", 100, ""), // TODO: Increase the max length to 200? text("content"), text("parsed_content"), createdAt(), {"lastReplyAt", "datetime", 0, false, false, ""}, {"lastReplyBy", "int", 0, false, false, ""}, {"lastReplyID", "int", 0, false, false, "0"}, {"createdBy", "int", 0, false, false, ""}, // TODO: Make this a foreign key bcol("is_closed", false), bcol("sticky", false), // TODO: Add an index for this {"parentID", "int", 0, false, false, "2"}, ccol("ip", 200, "''"), {"postCount", "int", 0, false, false, "1"}, {"likeCount", "int", 0, false, false, "0"}, {"attachCount", "int", 0, false, false, "0"}, {"words", "int", 0, false, false, "0"}, {"views", "int", 0, false, false, "0"}, //{"dayViews", "int", 0, false, false, "0"}, {"weekEvenViews", "int", 0, false, false, "0"}, {"weekOddViews", "int", 0, false, false, "0"}, ///{"weekViews", "int", 0, false, false, "0"}, ///{"lastWeekViews", "int", 0, false, false, "0"}, //{"monthViews", "int", 0, false, false, "0"}, // ? - A little hacky, maybe we could do something less likely to bite us with huge numbers of topics? // TODO: Add an index for this? //{"lastMonth", "datetime", 0, false, false, ""}, ccol("css_class", 100, "''"), {"poll", "int", 0, false, false, "0"}, ccol("data", 200, "''"), }, []tK{ {"tid", "primary", "", false}, {"title", "fulltext", "", false}, {"content", "fulltext", "", false}, }, ) createTable("replies", mysqlPre, mysqlCol, []tC{ {"rid", "int", 0, false, true, ""}, // TODO: Rename to replyID? {"tid", "int", 0, false, false, ""}, // TODO: Rename to topicID? text("content"), text("parsed_content"), createdAt(), {"createdBy", "int", 0, false, false, ""}, // TODO: Make this a foreign key {"lastEdit", "int", 0, false, false, "0"}, {"lastEditBy", "int", 0, false, false, "0"}, {"lastUpdated", "datetime", 0, false, false, ""}, ccol("ip", 200, "''"), {"likeCount", "int", 0, false, false, "0"}, {"attachCount", "int", 0, false, false, "0"}, {"words", "int", 0, false, false, "1"}, // ? - replies has a default of 1 and topics has 0? why? ccol("actionType", 20, "''"), {"poll", "int", 0, false, false, "0"}, }, []tK{ {"rid", "primary", "", false}, {"content", "fulltext", "", false}, }, ) createTable("attachments", mysqlPre, mysqlCol, []tC{ {"attachID", "int", 0, false, true, ""}, {"sectionID", "int", 0, false, false, "0"}, ccol("sectionTable", 200, "forums"), {"originID", "int", 0, false, false, ""}, ccol("originTable", 200, "replies"), {"uploadedBy", "int", 0, false, false, ""}, // TODO; Make this a foreign key ccol("path", 200, ""), ccol("extra", 200, ""), }, []tK{ {"attachID", "primary", "", false}, }, ) createTable("revisions", mysqlPre, mysqlCol, []tC{ {"reviseID", "int", 0, false, true, ""}, text("content"), {"contentID", "int", 0, false, false, ""}, ccol("contentType", 100, "replies"), createdAt(), // TODO: Add a createdBy column? }, []tK{ {"reviseID", "primary", "", false}, }, ) createTable("polls", mysqlPre, mysqlCol, []tC{ {"pollID", "int", 0, false, true, ""}, {"parentID", "int", 0, false, false, "0"}, ccol("parentTable", 100, "topics"), // topics, replies {"type", "int", 0, false, false, "0"}, {"options", "json", 0, false, false, ""}, {"votes", "int", 0, false, false, "0"}, }, []tK{ {"pollID", "primary", "", false}, }, ) createTable("polls_options", "", "", []tC{ {"pollID", "int", 0, false, false, ""}, {"option", "int", 0, false, false, "0"}, {"votes", "int", 0, false, false, "0"}, }, nil, ) createTable("polls_votes", mysqlPre, mysqlCol, []tC{ {"pollID", "int", 0, false, false, ""}, {"uid", "int", 0, false, false, ""}, // TODO: Make this a foreign key {"option", "int", 0, false, false, "0"}, createdAt("castAt"), ccol("ip", 200, "''"), }, nil, ) createTable("users_replies", mysqlPre, mysqlCol, []tC{ {"rid", "int", 0, false, true, ""}, {"uid", "int", 0, false, false, ""}, // TODO: Make this a foreign key text("content"), text("parsed_content"), createdAt(), {"createdBy", "int", 0, false, false, ""}, // TODO: Make this a foreign key {"lastEdit", "int", 0, false, false, "0"}, {"lastEditBy", "int", 0, false, false, "0"}, ccol("ip", 200, "''"), }, []tK{ {"rid", "primary", "", false}, }, ) createTable("likes", "", "", []tC{ {"weight", "tinyint", 0, false, false, "1"}, {"targetItem", "int", 0, false, false, ""}, ccol("targetType", 50, "replies"), {"sentBy", "int", 0, false, false, ""}, // TODO: Make this a foreign key createdAt(), {"recalc", "tinyint", 0, false, false, "0"}, }, nil, ) //columns("participants,createdBy,createdAt,lastReplyBy,lastReplyAt").Where("cid=?") createTable("conversations", "", "", []tC{ {"cid", "int", 0, false, true, ""}, {"createdBy", "int", 0, false, false, ""}, // TODO: Make this a foreign key createdAt(), {"lastReplyAt", "datetime", 0, false, false, ""}, {"lastReplyBy", "int", 0, false, false, ""}, }, []tK{ {"cid", "primary", "", false}, }, ) createTable("conversations_posts", "", "", []tC{ {"pid", "int", 0, false, true, ""}, {"cid", "int", 0, false, false, ""}, {"createdBy", "int", 0, false, false, ""}, ccol("body", 50, ""), ccol("post", 50, "''"), }, []tK{ {"pid", "primary", "", false}, }, ) createTable("conversations_participants", "", "", []tC{ {"uid", "int", 0, false, false, ""}, {"cid", "int", 0, false, false, ""}, }, nil, ) /* createTable("users_friends", "", "", []tC{ {"uid", "int", 0, false, false, ""}, {"uid2", "int", 0, false, false, ""}, }, nil, ) createTable("users_friends_invites", "", "", []tC{ {"requester", "int", 0, false, false, ""}, {"target", "int", 0, false, false, ""}, }, nil, ) */ createTable("users_blocks", "", "", []tC{ {"blocker", "int", 0, false, false, ""}, {"blockedUser", "int", 0, false, false, ""}, }, nil, ) createTable("activity_stream_matches", "", "", []tC{ {"watcher", "int", 0, false, false, ""}, // TODO: Make this a foreign key {"asid", "int", 0, false, false, ""}, // TODO: Make this a foreign key }, []tK{ {"asid,asid", "foreign", "activity_stream", true}, }, ) createTable("activity_stream", "", "", []tC{ {"asid", "int", 0, false, true, ""}, {"actor", "int", 0, false, false, ""}, /* the one doing the act */ // TODO: Make this a foreign key {"targetUser", "int", 0, false, false, ""}, /* the user who created the item the actor is acting on, some items like forums may lack a targetUser field */ ccol("event", 50, ""), /* mention, like, reply (as in the act of replying to an item, not the reply item type, you can "reply" to a forum by making a topic in it), friend_invite */ ccol("elementType", 50, ""), /* topic, post (calling it post here to differentiate it from the 'reply' event), forum, user */ // replacement for elementType tC{"elementTable", "int", 0, false, false, "0"}, {"elementID", "int", 0, false, false, ""}, /* the ID of the element being acted upon */ createdAt(), ccol("extra", 200, "''"), }, []tK{ {"asid", "primary", "", false}, }, ) createTable("activity_subscriptions", "", "", []tC{ {"user", "int", 0, false, false, ""}, // TODO: Make this a foreign key {"targetID", "int", 0, false, false, ""}, /* the ID of the element being acted upon */ ccol("targetType", 50, ""), /* topic, post (calling it post here to differentiate it from the 'reply' event), forum, user */ {"level", "int", 0, false, false, "0"}, /* 0: Mentions (aka the global default for any post), 1: Replies To You, 2: All Replies*/ }, nil, ) /* Due to MySQL's design, we have to drop the unique keys for table settings, plugins, and themes down from 200 to 180 or it will error */ createTable("settings", "", "", []tC{ ccol("name", 180, ""), ccol("content", 250, ""), ccol("type", 50, ""), ccol("constraints", 200, "''"), }, []tK{ {"name", "unique", "", false}, }, ) createTable("word_filters", "", "", []tC{ {"wfid", "int", 0, false, true, ""}, ccol("find", 200, ""), ccol("replacement", 200, ""), }, []tK{ {"wfid", "primary", "", false}, }, ) createTable("plugins", "", "", []tC{ ccol("uname", 180, ""), bcol("active", false), bcol("installed", false), }, []tK{ {"uname", "unique", "", false}, }, ) createTable("themes", "", "", []tC{ ccol("uname", 180, ""), bcol("default", false), //text("profileUserVars"), }, []tK{ {"uname", "unique", "", false}, }, ) createTable("widgets", "", "", []tC{ {"wid", "int", 0, false, true, ""}, {"position", "int", 0, false, false, ""}, ccol("side", 100, ""), ccol("type", 100, ""), bcol("active", false), ccol("location", 100, ""), text("data"), }, []tK{ {"wid", "primary", "", false}, }, ) createTable("menus", "", "", []tC{ {"mid", "int", 0, false, true, ""}, }, []tK{ {"mid", "primary", "", false}, }, ) createTable("menu_items", "", "", []tC{ {"miid", "int", 0, false, true, ""}, {"mid", "int", 0, false, false, ""}, ccol("name", 200, "''"), ccol("htmlID", 200, "''"), ccol("cssClass", 200, "''"), ccol("position", 100, ""), ccol("path", 200, "''"), ccol("aria", 200, "''"), ccol("tooltip", 200, "''"), ccol("tmplName", 200, "''"), {"order", "int", 0, false, false, "0"}, bcol("guestOnly", false), bcol("memberOnly", false), bcol("staffOnly", false), bcol("adminOnly", false), }, []tK{ {"miid", "primary", "", false}, }, ) createTable("pages", mysqlPre, mysqlCol, []tC{ {"pid", "int", 0, false, true, ""}, //ccol("path", 200, ""), ccol("name", 200, ""), ccol("title", 200, ""), text("body"), // TODO: Make this a table? text("allowedGroups"), {"menuID", "int", 0, false, false, "-1"}, // simple sidebar menu }, []tK{ {"pid", "primary", "", false}, }, ) createTable("registration_logs", "", "", []tC{ {"rlid", "int", 0, false, true, ""}, ccol("username", 100, ""), {"email", "varchar", 100, false, false, ""}, ccol("failureReason", 100, ""), bcol("success", false), // Did this attempt succeed? ccol("ipaddress", 200, ""), createdAt("doneAt"), }, []tK{ {"rlid", "primary", "", false}, }, ) createTable("login_logs", "", "", []tC{ {"lid", "int", 0, false, true, ""}, {"uid", "int", 0, false, false, ""}, bcol("success", false), // Did this attempt succeed? ccol("ipaddress", 200, ""), createdAt("doneAt"), }, []tK{ {"lid", "primary", "", false}, }, ) createTable("moderation_logs", "", "", []tC{ ccol("action", 100, ""), {"elementID", "int", 0, false, false, ""}, ccol("elementType", 100, ""), ccol("ipaddress", 200, ""), {"actorID", "int", 0, false, false, ""}, // TODO: Make this a foreign key {"doneAt", "datetime", 0, false, false, ""}, text("extra"), }, nil, ) createTable("administration_logs", "", "", []tC{ ccol("action", 100, ""), {"elementID", "int", 0, false, false, ""}, ccol("elementType", 100, ""), ccol("ipaddress", 200, ""), {"actorID", "int", 0, false, false, ""}, // TODO: Make this a foreign key {"doneAt", "datetime", 0, false, false, ""}, text("extra"), }, nil, ) createTable("viewchunks", "", "", []tC{ {"count", "int", 0, false, false, "0"}, {"avg", "int", 0, false, false, "0"}, {"createdAt", "datetime", 0, false, false, ""}, ccol("route", 200, ""), // TODO: set a default empty here }, nil, ) createTable("viewchunks_agents", "", "", []tC{ {"count", "int", 0, false, false, "0"}, {"createdAt", "datetime", 0, false, false, ""}, ccol("browser", 200, ""), // googlebot, firefox, opera, etc. //ccol("version",0,""), // the version of the browser or bot }, nil, ) createTable("viewchunks_systems", "", "", []tC{ {"count", "int", 0, false, false, "0"}, {"createdAt", "datetime", 0, false, false, ""}, ccol("system", 200, ""), // windows, android, unknown, etc. }, nil, ) createTable("viewchunks_langs", "", "", []tC{ {"count", "int", 0, false, false, "0"}, {"createdAt", "datetime", 0, false, false, ""}, ccol("lang", 200, ""), // en, ru, etc. }, nil, ) createTable("viewchunks_referrers", "", "", []tC{ {"count", "int", 0, false, false, "0"}, {"createdAt", "datetime", 0, false, false, ""}, ccol("domain", 200, ""), }, nil, ) createTable("viewchunks_forums", "", "", []tC{ {"count", "int", 0, false, false, "0"}, {"createdAt", "datetime", 0, false, false, ""}, {"forum", "int", 0, false, false, ""}, }, nil, ) createTable("topicchunks", "", "", []tC{ {"count", "int", 0, false, false, "0"}, {"createdAt", "datetime", 0, false, false, ""}, // TODO: Add a column for the parent forum? }, nil, ) createTable("postchunks", "", "", []tC{ {"count", "int", 0, false, false, "0"}, {"createdAt", "datetime", 0, false, false, ""}, // TODO: Add a column for the parent topic / profile? }, nil, ) createTable("memchunks", "", "", []tC{ {"count", "int", 0, false, false, "0"}, {"stack", "int", 0, false, false, "0"}, {"heap", "int", 0, false, false, "0"}, {"createdAt", "datetime", 0, false, false, ""}, }, nil, ) createTable("perfchunks", "", "", []tC{ {"low", "int", 0, false, false, "0"}, {"high", "int", 0, false, false, "0"}, {"avg", "int", 0, false, false, "0"}, {"createdAt", "datetime", 0, false, false, ""}, }, nil, ) createTable("sync", "", "", []tC{ {"last_update", "datetime", 0, false, false, ""}, }, nil, ) createTable("updates", "", "", []tC{ {"dbVersion", "int", 0, false, false, "0"}, }, nil, ) createTable("meta", "", "", []tC{ ccol("name", 200, ""), ccol("value", 200, ""), }, nil, ) /*createTable("tables", "", "", []tC{ {"id", "int", 0, false, true, ""}, ccol("name", 200, ""), }, []tK{ {"id", "primary", "", false}, {"name", "unique", "", false}, }, )*/ return err } ================================================ FILE: common/activity_stream.go ================================================ package common import ( "database/sql" qgen "github.com/Azareal/Gosora/query_gen" ) var Activity ActivityStream type ActivityStream interface { Add(a Alert) (int, error) Get(id int) (Alert, error) Delete(id int) error DeleteByParams(event string, targetID int, targetType string) error DeleteByParamsExtra(event string, targetID int, targetType, extra string) error AidsByParams(event string, elementID int, elementType string) (aids []int, err error) AidsByParamsExtra(event string, elementID int, elementType, extra string) (aids []int, err error) Count() (count int) } type DefaultActivityStream struct { add *sql.Stmt get *sql.Stmt delete *sql.Stmt deleteByParams *sql.Stmt deleteByParamsExtra *sql.Stmt aidsByParams *sql.Stmt aidsByParamsExtra *sql.Stmt count *sql.Stmt } func NewDefaultActivityStream(acc *qgen.Accumulator) (*DefaultActivityStream, error) { as := "activity_stream" cols := "actor,targetUser,event,elementType,elementID,createdAt,extra" return &DefaultActivityStream{ add: acc.Insert(as).Columns(cols).Fields("?,?,?,?,?,UTC_TIMESTAMP(),?").Prepare(), get: acc.Select(as).Columns(cols).Where("asid=?").Prepare(), delete: acc.Delete(as).Where("asid=?").Prepare(), deleteByParams: acc.Delete(as).Where("event=? AND elementID=? AND elementType=?").Prepare(), deleteByParamsExtra: acc.Delete(as).Where("event=? AND elementID=? AND elementType=? AND extra=?").Prepare(), aidsByParams: acc.Select(as).Columns("asid").Where("event=? AND elementID=? AND elementType=?").Prepare(), aidsByParamsExtra: acc.Select(as).Columns("asid").Where("event=? AND elementID=? AND elementType=? AND extra=?").Prepare(), count: acc.Count(as).Prepare(), }, acc.FirstError() } func (s *DefaultActivityStream) Add(a Alert) (int, error) { res, err := s.add.Exec(a.ActorID, a.TargetUserID, a.Event, a.ElementType, a.ElementID, a.Extra) if err != nil { return 0, err } lastID, err := res.LastInsertId() return int(lastID), err } func (s *DefaultActivityStream) Get(id int) (Alert, error) { a := Alert{ASID: id} err := s.get.QueryRow(id).Scan(&a.ActorID, &a.TargetUserID, &a.Event, &a.ElementType, &a.ElementID, &a.CreatedAt, &a.Extra) return a, err } func (s *DefaultActivityStream) Delete(id int) error { _, err := s.delete.Exec(id) return err } func (s *DefaultActivityStream) DeleteByParams(event string, elementID int, elementType string) error { _, err := s.deleteByParams.Exec(event, elementID, elementType) return err } func (s *DefaultActivityStream) DeleteByParamsExtra(event string, elementID int, elementType, extra string) error { _, err := s.deleteByParamsExtra.Exec(event, elementID, elementType, extra) return err } func (s *DefaultActivityStream) AidsByParams(event string, elementID int, elementType string) (aids []int, err error) { rows, err := s.aidsByParams.Query(event, elementID, elementType) if err != nil { return nil, err } defer rows.Close() for rows.Next() { var aid int if err := rows.Scan(&aid); err != nil { return nil, err } aids = append(aids, aid) } return aids, rows.Err() } func (s *DefaultActivityStream) AidsByParamsExtra(event string, elementID int, elementType, extra string) (aids []int, e error) { rows, e := s.aidsByParamsExtra.Query(event, elementID, elementType, extra) if e != nil { return nil, e } defer rows.Close() for rows.Next() { var aid int if e := rows.Scan(&aid); e != nil { return nil, e } aids = append(aids, aid) } return aids, rows.Err() } // Count returns the total number of activity stream items func (s *DefaultActivityStream) Count() (count int) { return Count(s.count) } ================================================ FILE: common/activity_stream_matches.go ================================================ package common import ( "database/sql" qgen "github.com/Azareal/Gosora/query_gen" ) var ActivityMatches ActivityStreamMatches type ActivityStreamMatches interface { Add(watcher, asid int) error Delete(watcher, asid int) error DeleteAndCountChanged(watcher, asid int) (int, error) CountAsid(asid int) int } type DefaultActivityStreamMatches struct { add *sql.Stmt delete *sql.Stmt countAsid *sql.Stmt } func NewDefaultActivityStreamMatches(acc *qgen.Accumulator) (*DefaultActivityStreamMatches, error) { asm := "activity_stream_matches" return &DefaultActivityStreamMatches{ add: acc.Insert(asm).Columns("watcher,asid").Fields("?,?").Prepare(), delete: acc.Delete(asm).Where("watcher=? AND asid=?").Prepare(), countAsid: acc.Count(asm).Where("asid=?").Prepare(), }, acc.FirstError() } func (s *DefaultActivityStreamMatches) Add(watcher, asid int) error { _, e := s.add.Exec(watcher, asid) return e } func (s *DefaultActivityStreamMatches) Delete(watcher, asid int) error { _, e := s.delete.Exec(watcher, asid) return e } func (s *DefaultActivityStreamMatches) DeleteAndCountChanged(watcher, asid int) (int, error) { res, e := s.delete.Exec(watcher, asid) if e != nil { return 0, e } c64, e := res.RowsAffected() return int(c64), e } func (s *DefaultActivityStreamMatches) CountAsid(asid int) int { return Countf(s.countAsid, asid) } ================================================ FILE: common/alerts/tmpls.go ================================================ package alerts // TODO: Move the other alert related stuff to package alerts, maybe move notification logic here too? type AlertItem struct { ASID int Path string Message string Avatar string } ================================================ FILE: common/alerts.go ================================================ /* * * Gosora Alerts System * Copyright Azareal 2017 - 2020 * */ package common import ( "database/sql" "errors" "strconv" "strings" "time" //"fmt" "github.com/Azareal/Gosora/common/phrases" qgen "github.com/Azareal/Gosora/query_gen" ) type Alert struct { ASID int ActorID int TargetUserID int Event string ElementType string ElementID int CreatedAt time.Time Extra string Actor *User } type AlertStmts struct { notifyWatchers *sql.Stmt getWatchers *sql.Stmt } var alertStmts AlertStmts // TODO: Move these statements into some sort of activity abstraction // TODO: Rewrite the alerts logic func init() { DbInits.Add(func(acc *qgen.Accumulator) error { alertStmts = AlertStmts{ notifyWatchers: acc.SimpleInsertInnerJoin( qgen.DBInsert{"activity_stream_matches", "watcher,asid", ""}, qgen.DBJoin{"activity_stream", "activity_subscriptions", "activity_subscriptions.user, activity_stream.asid", "activity_subscriptions.targetType = activity_stream.elementType AND activity_subscriptions.targetID = activity_stream.elementID AND activity_subscriptions.user != activity_stream.actor", "asid=?", "", ""}, ), getWatchers: acc.SimpleInnerJoin("activity_stream", "activity_subscriptions", "activity_subscriptions.user", "activity_subscriptions.targetType = activity_stream.elementType AND activity_subscriptions.targetID = activity_stream.elementID AND activity_subscriptions.user != activity_stream.actor", "asid=?", "", ""), } return acc.FirstError() }) } const AlertsGrowHint = len(`{"msgs":[],"count":,"tc":}`) + 1 + 10 // TODO: See if we can json.Marshal instead? func escapeTextInJson(in string) string { in = strings.Replace(in, "\"", "\\\"", -1) return strings.Replace(in, "/", "\\/", -1) } func BuildAlert(a Alert, user User /* The current user */) (out string, err error) { var targetUser *User if a.Actor == nil { a.Actor, err = Users.Get(a.ActorID) if err != nil { return "", errors.New(phrases.GetErrorPhrase("alerts_no_actor")) } } /*if a.ElementType != "forum" { targetUser, err = users.Get(a.TargetUserID) if err != nil { LocalErrorJS("Unable to find the target user",w,r) return } }*/ if a.Event == "friend_invite" { return buildAlertString(".new_friend_invite", []string{a.Actor.Name}, a.Actor.Link, a.Actor.Avatar, a.ASID), nil } // Not that many events for us to handle in a forum if a.ElementType == "forum" { if a.Event == "reply" { topic, err := Topics.Get(a.ElementID) if err != nil { DebugLogf("Unable to find linked topic %d", a.ElementID) return "", errors.New(phrases.GetErrorPhrase("alerts_no_linked_topic")) } // Store the forum ID in the targetUser column instead of making a new one? o.O // Add an additional column for extra information later on when we add the ability to link directly to posts. We don't need the forum data for now... return buildAlertString(".forum_new_topic", []string{a.Actor.Name, topic.Title}, topic.Link, a.Actor.Avatar, a.ASID), nil } return buildAlertString(".forum_unknown_action", []string{a.Actor.Name}, "", a.Actor.Avatar, a.ASID), nil } var url, area, phraseName string own := false // TODO: Avoid loading a bit of data twice switch a.ElementType { case "convo": convo, err := Convos.Get(a.ElementID) if err != nil { DebugLogf("Unable to find linked convo %d", a.ElementID) return "", errors.New(phrases.GetErrorPhrase("alerts_no_linked_convo")) } url = convo.Link case "topic": topic, err := Topics.Get(a.ElementID) if err != nil { DebugLogf("Unable to find linked topic %d", a.ElementID) return "", errors.New(phrases.GetErrorPhrase("alerts_no_linked_topic")) } url = topic.Link area = topic.Title own = a.TargetUserID == user.ID case "user": targetUser, err = Users.Get(a.ElementID) if err != nil { DebugLogf("Unable to find target user %d", a.ElementID) return "", errors.New(phrases.GetErrorPhrase("alerts_no_target_user")) } area = targetUser.Name url = targetUser.Link own = a.TargetUserID == user.ID case "post": topic, err := TopicByReplyID(a.ElementID) if err != nil { DebugLogf("Unable to find linked topic by reply ID %d", a.ElementID) return "", errors.New(phrases.GetErrorPhrase("alerts_no_linked_topic_by_reply")) } url = topic.Link area = topic.Title own = a.TargetUserID == user.ID default: return "", errors.New(phrases.GetErrorPhrase("alerts_invalid_elementtype")) } badEv := false switch a.Event { case "create", "like", "mention", "reply": // skip default: badEv = true } if own && !badEv { phraseName = "." + a.ElementType + "_own_" + a.Event } else if !badEv { phraseName = "." + a.ElementType + "_" + a.Event } else if own { phraseName = "." + a.ElementType + "_own" } else { phraseName = "." + a.ElementType } return buildAlertString(phraseName, []string{a.Actor.Name, area}, url, a.Actor.Avatar, a.ASID), nil } func buildAlertString(msg string, sub []string, path, avatar string, asid int) string { var sb strings.Builder buildAlertSb(&sb, msg, sub, path, avatar, asid) return sb.String() } const AlertsGrowHint2 = len(`{"msg":"","sub":[],"path":"","img":"","id":}`) + 5 + 3 + 1 + 1 + 1 // TODO: Use a string builder? func buildAlertSb(sb *strings.Builder, msg string, sub []string, path, avatar string, asid int) { sb.WriteString(`{"msg":"`) sb.WriteString(escapeTextInJson(msg)) sb.WriteString(`","sub":[`) for i, it := range sub { if i != 0 { sb.WriteString(",\"") } else { sb.WriteString("\"") } sb.WriteString(escapeTextInJson(it)) sb.WriteString("\"") } sb.WriteString(`],"path":"`) sb.WriteString(escapeTextInJson(path)) sb.WriteString(`","img":"`) sb.WriteString(escapeTextInJson(avatar)) sb.WriteString(`","id":`) sb.WriteString(strconv.Itoa(asid)) sb.WriteRune('}') } func BuildAlertSb(sb *strings.Builder, a *Alert, u *User /* The current user */) (err error) { var targetUser *User if a.Actor == nil { a.Actor, err = Users.Get(a.ActorID) if err != nil { return errors.New(phrases.GetErrorPhrase("alerts_no_actor")) } } /*if a.ElementType != "forum" { targetUser, err = users.Get(a.TargetUserID) if err != nil { LocalErrorJS("Unable to find the target user",w,r) return } }*/ if a.Event == "friend_invite" { buildAlertSb(sb, ".new_friend_invite", []string{a.Actor.Name}, a.Actor.Link, a.Actor.Avatar, a.ASID) return nil } // Not that many events for us to handle in a forum if a.ElementType == "forum" { if a.Event == "reply" { topic, err := Topics.Get(a.ElementID) if err != nil { DebugLogf("Unable to find linked topic %d", a.ElementID) return errors.New(phrases.GetErrorPhrase("alerts_no_linked_topic")) } // Store the forum ID in the targetUser column instead of making a new one? o.O // Add an additional column for extra information later on when we add the ability to link directly to posts. We don't need the forum data for now... buildAlertSb(sb, ".forum_new_topic", []string{a.Actor.Name, topic.Title}, topic.Link, a.Actor.Avatar, a.ASID) return nil } buildAlertSb(sb, ".forum_unknown_action", []string{a.Actor.Name}, "", a.Actor.Avatar, a.ASID) return nil } var url, area string own := false // TODO: Avoid loading a bit of data twice switch a.ElementType { case "convo": convo, err := Convos.Get(a.ElementID) if err != nil { DebugLogf("Unable to find linked convo %d", a.ElementID) return errors.New(phrases.GetErrorPhrase("alerts_no_linked_convo")) } url = convo.Link case "topic": topic, err := Topics.Get(a.ElementID) if err != nil { DebugLogf("Unable to find linked topic %d", a.ElementID) return errors.New(phrases.GetErrorPhrase("alerts_no_linked_topic")) } url = topic.Link area = topic.Title own = a.TargetUserID == u.ID case "user": targetUser, err = Users.Get(a.ElementID) if err != nil { DebugLogf("Unable to find target user %d", a.ElementID) return errors.New(phrases.GetErrorPhrase("alerts_no_target_user")) } area = targetUser.Name url = targetUser.Link own = a.TargetUserID == u.ID case "post": t, err := TopicByReplyID(a.ElementID) if err != nil { DebugLogf("Unable to find linked topic by reply ID %d", a.ElementID) return errors.New(phrases.GetErrorPhrase("alerts_no_linked_topic_by_reply")) } url = t.Link area = t.Title own = a.TargetUserID == u.ID default: return errors.New(phrases.GetErrorPhrase("alerts_invalid_elementtype")) } sb.WriteString(`{"msg":".`) sb.WriteString(a.ElementType) if own { sb.WriteString("_own_") } else { sb.WriteRune('_') } switch a.Event { case "create", "like", "mention", "reply": sb.WriteString(a.Event) } sb.WriteString(`","sub":["`) sb.WriteString(escapeTextInJson(a.Actor.Name)) sb.WriteString("\",\"") sb.WriteString(escapeTextInJson(area)) sb.WriteString(`"],"path":"`) sb.WriteString(escapeTextInJson(url)) sb.WriteString(`","img":"`) sb.WriteString(escapeTextInJson(a.Actor.Avatar)) sb.WriteString(`","id":`) sb.WriteString(strconv.Itoa(a.ASID)) sb.WriteRune('}') return nil } //const AlertsGrowHint3 = len(`{"msg":"._","sub":["",""],"path":"","img":"","id":}`) + 3 + 2 + 2 + 2 + 2 + 1 // TODO: Create a notifier structure? func AddActivityAndNotifyAll(a Alert) error { id, err := Activity.Add(a) if err != nil { return err } return NotifyWatchers(id) } // TODO: Create a notifier structure? func AddActivityAndNotifyTarget(a Alert) error { id, err := Activity.Add(a) if err != nil { return err } err = ActivityMatches.Add(a.TargetUserID, id) if err != nil { return err } a.ASID = id // Live alerts, if the target is online and WebSockets is enabled if EnableWebsockets { go func() { defer EatPanics() _ = WsHub.pushAlert(a.TargetUserID, a) //fmt.Println("err:",err) }() } return nil } // TODO: Create a notifier structure? func NotifyWatchers(asid int) error { _, err := alertStmts.notifyWatchers.Exec(asid) if err != nil { return err } // Alert the subscribers about this without blocking us from doing something else if EnableWebsockets { go func() { defer EatPanics() notifyWatchers(asid) }() } return nil } func notifyWatchers(asid int) { rows, e := alertStmts.getWatchers.Query(asid) if e != nil && e != ErrNoRows { LogError(e) return } defer rows.Close() var uid int var uids []int for rows.Next() { if e := rows.Scan(&uid); e != nil { LogError(e) return } uids = append(uids, uid) } if e = rows.Err(); e != nil { LogError(e) return } alert, e := Activity.Get(asid) if e != nil && e != ErrNoRows { LogError(e) return } _ = WsHub.pushAlerts(uids, alert) } func DismissAlert(uid, aid int) { _ = WsHub.PushMessage(uid, `{"event":"dismiss-alert","id":`+strconv.Itoa(aid)+`}`) } ================================================ FILE: common/analytics.go ================================================ package common import ( "database/sql" "log" "time" qgen "github.com/Azareal/Gosora/query_gen" ) var Analytics AnalyticsStore type AnalyticsTimeRange struct { Quantity int Unit string Slices int SliceWidth int Range string } type AnalyticsStore interface { FillViewMap(tbl string, tr *AnalyticsTimeRange, labelList []int64, viewMap map[int64]int64, param string, args ...interface{}) (map[int64]int64, error) } type DefaultAnalytics struct { } func NewDefaultAnalytics() *DefaultAnalytics { return &DefaultAnalytics{} } /* rows, e := qgen.NewAcc().Select("viewchunks_systems").Columns("count,createdAt").Where("system=?").DateCutoff("createdAt", timeRange.Quantity, timeRange.Unit).Query(system) if e != nil && e != sql.ErrNoRows { return c.InternalError(e, w, r) } viewMap, e = c.AnalyticsRowsToViewMap(rows, labelList, viewMap) if e != nil { return c.InternalError(e, w, r) } */ func (s *DefaultAnalytics) FillViewMap(tbl string, tr *AnalyticsTimeRange, labelList []int64, viewMap map[int64]int64, param string, args ...interface{}) (map[int64]int64, error) { ac := qgen.NewAcc().Select(tbl).Columns("count,createdAt") if param != "" { ac = ac.Where(param + "=?") } rows, e := ac.DateCutoff("createdAt", tr.Quantity, tr.Unit).Query(args...) if e != nil && e != sql.ErrNoRows { return nil, e } return AnalyticsRowsToViewMap(rows, labelList, viewMap) } // TODO: Clamp it rather than using an offset off the current time to avoid chaotic changes in stats as adjacent sets converge and diverge? func AnalyticsTimeRangeToLabelList(tr *AnalyticsTimeRange) (revLabelList []int64, labelList []int64, viewMap map[int64]int64) { viewMap = make(map[int64]int64) currentTime := time.Now().Unix() for i := 1; i <= tr.Slices; i++ { label := currentTime - int64(i*tr.SliceWidth) revLabelList = append(revLabelList, label) viewMap[label] = 0 } labelList = append(labelList, revLabelList...) return revLabelList, labelList, viewMap } func AnalyticsRowsToViewMap(rows *sql.Rows, labelList []int64, viewMap map[int64]int64) (map[int64]int64, error) { defer rows.Close() for rows.Next() { var count int64 var createdAt time.Time e := rows.Scan(&count, &createdAt) if e != nil { return viewMap, e } unixCreatedAt := createdAt.Unix() // TODO: Bulk log this if Dev.SuperDebug { log.Print("count: ", count) log.Print("createdAt: ", createdAt, " - ", unixCreatedAt) } for _, value := range labelList { if unixCreatedAt > value { viewMap[value] += count break } } } return viewMap, rows.Err() } ================================================ FILE: common/attachments.go ================================================ package common import ( "database/sql" "errors" //"fmt" "os" "path/filepath" "strings" qgen "github.com/Azareal/Gosora/query_gen" ) var Attachments AttachmentStore var ErrCorruptAttachPath = errors.New("corrupt attachment path") type MiniAttachment struct { ID int SectionID int OriginID int UploadedBy int Path string Extra string Image bool Ext string } type Attachment struct { ID int SectionTable string SectionID int OriginTable string OriginID int UploadedBy int Path string Extra string Image bool Ext string } type AttachmentStore interface { GetForRenderRoute(filename string, sid int, sectionTable string) (*Attachment, error) FGet(id int) (*Attachment, error) Get(id int) (*MiniAttachment, error) MiniGetList(originTable string, originID int) (alist []*MiniAttachment, err error) BulkMiniGetList(originTable string, ids []int) (amap map[int][]*MiniAttachment, err error) Add(sectionID int, sectionTable string, originID int, originTable string, uploadedBy int, path, extra string) (int, error) MoveTo(sectionID, originID int, originTable string) error MoveToByExtra(sectionID int, originTable, extra string) error Count() int CountIn(originTable string, oid int) int CountInPath(path string) int Delete(id int) error AddLinked(otable string, oid int) (err error) RemoveLinked(otable string, oid int) (err error) } type DefaultAttachmentStore struct { getForRenderRoute *sql.Stmt fget *sql.Stmt get *sql.Stmt getByObj *sql.Stmt add *sql.Stmt count *sql.Stmt countIn *sql.Stmt countInPath *sql.Stmt move *sql.Stmt moveByExtra *sql.Stmt delete *sql.Stmt replyUpdateAttachs *sql.Stmt topicUpdateAttachs *sql.Stmt } func NewDefaultAttachmentStore(acc *qgen.Accumulator) (*DefaultAttachmentStore, error) { a := "attachments" return &DefaultAttachmentStore{ getForRenderRoute: acc.Select(a).Columns("sectionTable, originID, originTable, uploadedBy, path").Where("path=? AND sectionID=? AND sectionTable=?").Prepare(), fget: acc.Select(a).Columns("originTable, originID, sectionTable, sectionID, uploadedBy, path, extra").Where("attachID=?").Prepare(), get: acc.Select(a).Columns("originID, sectionID, uploadedBy, path, extra").Where("attachID=?").Prepare(), getByObj: acc.Select(a).Columns("attachID, sectionID, uploadedBy, path, extra").Where("originTable=? AND originID=?").Prepare(), add: acc.Insert(a).Columns("sectionID, sectionTable, originID, originTable, uploadedBy, path, extra").Fields("?,?,?,?,?,?,?").Prepare(), count: acc.Count(a).Prepare(), countIn: acc.Count(a).Where("originTable=? and originID=?").Prepare(), countInPath: acc.Count(a).Where("path=?").Prepare(), move: acc.Update(a).Set("sectionID=?").Where("originID=? AND originTable=?").Prepare(), moveByExtra: acc.Update(a).Set("sectionID=?").Where("originTable=? AND extra=?").Prepare(), delete: acc.Delete(a).Where("attachID=?").Prepare(), // TODO: Less race-y attachment count updates replyUpdateAttachs: acc.Update("replies").Set("attachCount=?").Where("rid=?").Prepare(), topicUpdateAttachs: acc.Update("topics").Set("attachCount=?").Where("tid=?").Prepare(), }, acc.FirstError() } // TODO: Revamp this to make it less of a copy-paste from the original code in the route // ! Lacks some attachment initialisation code func (s *DefaultAttachmentStore) GetForRenderRoute(filename string, sid int, sectionTable string) (*Attachment, error) { a := &Attachment{SectionID: sid} e := s.getForRenderRoute.QueryRow(filename, sid, sectionTable).Scan(&a.SectionTable, &a.OriginID, &a.OriginTable, &a.UploadedBy, &a.Path) // TODO: Initialise attachment struct fields? return a, e } func (s *DefaultAttachmentStore) MiniGetList(originTable string, originID int) (alist []*MiniAttachment, err error) { rows, err := s.getByObj.Query(originTable, originID) defer rows.Close() for rows.Next() { a := &MiniAttachment{OriginID: originID} err := rows.Scan(&a.ID, &a.SectionID, &a.UploadedBy, &a.Path, &a.Extra) if err != nil { return nil, err } a.Ext = strings.TrimPrefix(filepath.Ext(a.Path), ".") if len(a.Ext) == 0 { return nil, ErrCorruptAttachPath } a.Image = ImageFileExts.Contains(a.Ext) alist = append(alist, a) } if err = rows.Err(); err != nil { return nil, err } if len(alist) == 0 { err = sql.ErrNoRows } return alist, err } func (s *DefaultAttachmentStore) BulkMiniGetList(originTable string, ids []int) (amap map[int][]*MiniAttachment, err error) { if len(ids) == 0 { return nil, sql.ErrNoRows } if len(ids) == 1 { res, err := s.MiniGetList(originTable, ids[0]) return map[int][]*MiniAttachment{ids[0]: res}, err } amap = make(map[int][]*MiniAttachment) var buffer []*MiniAttachment var currentID int rows, err := qgen.NewAcc().Select("attachments").Columns("attachID,sectionID,originID,uploadedBy,path").Where("originTable=?").In("originID", ids).Orderby("originID ASC").Query(originTable) defer rows.Close() for rows.Next() { a := &MiniAttachment{} err := rows.Scan(&a.ID, &a.SectionID, &a.OriginID, &a.UploadedBy, &a.Path) if err != nil { return nil, err } a.Ext = strings.TrimPrefix(filepath.Ext(a.Path), ".") if len(a.Ext) == 0 { return nil, ErrCorruptAttachPath } a.Image = ImageFileExts.Contains(a.Ext) if currentID == 0 { currentID = a.OriginID } if a.OriginID != currentID { if len(buffer) > 0 { amap[currentID] = buffer currentID = a.OriginID buffer = nil } } buffer = append(buffer, a) } if len(buffer) > 0 { amap[currentID] = buffer } return amap, rows.Err() } func (s *DefaultAttachmentStore) FGet(id int) (*Attachment, error) { a := &Attachment{ID: id} e := s.fget.QueryRow(id).Scan(&a.OriginTable, &a.OriginID, &a.SectionTable, &a.SectionID, &a.UploadedBy, &a.Path, &a.Extra) if e != nil { return nil, e } a.Ext = strings.TrimPrefix(filepath.Ext(a.Path), ".") if len(a.Ext) == 0 { return nil, ErrCorruptAttachPath } a.Image = ImageFileExts.Contains(a.Ext) return a, nil } func (s *DefaultAttachmentStore) Get(id int) (*MiniAttachment, error) { a := &MiniAttachment{ID: id} err := s.get.QueryRow(id).Scan(&a.OriginID, &a.SectionID, &a.UploadedBy, &a.Path, &a.Extra) if err != nil { return nil, err } a.Ext = strings.TrimPrefix(filepath.Ext(a.Path), ".") if len(a.Ext) == 0 { return nil, ErrCorruptAttachPath } a.Image = ImageFileExts.Contains(a.Ext) return a, nil } func (s *DefaultAttachmentStore) Add(sectionID int, sectionTable string, originID int, originTable string, uploadedBy int, path, extra string) (int, error) { res, err := s.add.Exec(sectionID, sectionTable, originID, originTable, uploadedBy, path, extra) if err != nil { return 0, err } lid, err := res.LastInsertId() return int(lid), err } func (s *DefaultAttachmentStore) MoveTo(sectionID, originID int, originTable string) error { _, err := s.move.Exec(sectionID, originID, originTable) return err } func (s *DefaultAttachmentStore) MoveToByExtra(sectionID int, originTable, extra string) error { _, err := s.moveByExtra.Exec(sectionID, originTable, extra) return err } func (s *DefaultAttachmentStore) Count() (count int) { e := s.count.QueryRow().Scan(&count) if e != nil { LogError(e) } return count } func (s *DefaultAttachmentStore) CountIn(originTable string, oid int) (count int) { e := s.countIn.QueryRow(originTable, oid).Scan(&count) if e != nil { LogError(e) } return count } func (s *DefaultAttachmentStore) CountInPath(path string) (count int) { e := s.countInPath.QueryRow(path).Scan(&count) if e != nil { LogError(e) } return count } func (s *DefaultAttachmentStore) Delete(id int) error { _, e := s.delete.Exec(id) return e } // TODO: Split this out of this store func (s *DefaultAttachmentStore) AddLinked(otable string, oid int) (err error) { switch otable { case "topics": _, err = s.topicUpdateAttachs.Exec(s.CountIn(otable, oid), oid) if err != nil { return err } err = Topics.Reload(oid) case "replies": _, err = s.replyUpdateAttachs.Exec(s.CountIn(otable, oid), oid) if err != nil { return err } err = Rstore.GetCache().Remove(oid) } if err == sql.ErrNoRows { err = nil } return err } // TODO: Split this out of this store func (s *DefaultAttachmentStore) RemoveLinked(otable string, oid int) (err error) { switch otable { case "topics": _, err = s.topicUpdateAttachs.Exec(s.CountIn(otable, oid), oid) if err != nil { return err } if tc := Topics.GetCache(); tc != nil { tc.Remove(oid) } case "replies": _, err = s.replyUpdateAttachs.Exec(s.CountIn(otable, oid), oid) if err != nil { return err } err = Rstore.GetCache().Remove(oid) } return err } // TODO: Add a table for the files and lock the file row when performing tasks related to the file func DeleteAttachment(aid int) error { a, err := Attachments.FGet(aid) if err != nil { return err } err = deleteAttachment(a) if err != nil { return err } _ = Attachments.RemoveLinked(a.OriginTable, a.OriginID) return nil } func deleteAttachment(a *Attachment) error { err := Attachments.Delete(a.ID) if err != nil { return err } count := Attachments.CountInPath(a.Path) if count == 0 { err := os.Remove("./attachs/" + a.Path) if err != nil { return err } } return nil } ================================================ FILE: common/audit_logs.go ================================================ package common import ( "database/sql" "time" qgen "github.com/Azareal/Gosora/query_gen" ) var ModLogs LogStore var AdminLogs LogStore type LogItem struct { Action string ElementID int ElementType string IP string ActorID int DoneAt string Extra string } type LogStore interface { Create(action string, elementID int, elementType, ip string, actorID int) (err error) CreateExtra(action string, elementID int, elementType, ip string, actorID int, extra string) (err error) Count() int GetOffset(offset, perPage int) (logs []LogItem, err error) } type SQLModLogStore struct { create *sql.Stmt count *sql.Stmt getOffset *sql.Stmt } func NewModLogStore(acc *qgen.Accumulator) (*SQLModLogStore, error) { ml := "moderation_logs" // TODO: Shorten name of ipaddress column to ip cols := "action, elementID, elementType, ipaddress, actorID, doneAt, extra" return &SQLModLogStore{ create: acc.Insert(ml).Columns(cols).Fields("?,?,?,?,?,UTC_TIMESTAMP(),?").Prepare(), count: acc.Count(ml).Prepare(), getOffset: acc.Select(ml).Columns(cols).Orderby("doneAt DESC").Limit("?,?").Prepare(), }, acc.FirstError() } // TODO: Make a store for this? func (s *SQLModLogStore) Create(action string, elementID int, elementType, ip string, actorID int) (err error) { return s.CreateExtra(action, elementID, elementType, ip, actorID, "") } func (s *SQLModLogStore) CreateExtra(action string, elementID int, elementType, ip string, actorID int, extra string) (err error) { _, err = s.create.Exec(action, elementID, elementType, ip, actorID, extra) return err } func (s *SQLModLogStore) Count() (count int) { err := s.count.QueryRow().Scan(&count) if err != nil { LogError(err) } return count } func buildLogList(rows *sql.Rows) (logs []LogItem, err error) { for rows.Next() { var l LogItem var doneAt time.Time err := rows.Scan(&l.Action, &l.ElementID, &l.ElementType, &l.IP, &l.ActorID, &doneAt, &l.Extra) if err != nil { return logs, err } l.DoneAt = doneAt.Format("2006-01-02 15:04:05") logs = append(logs, l) } return logs, rows.Err() } func (s *SQLModLogStore) GetOffset(offset, perPage int) (logs []LogItem, err error) { rows, err := s.getOffset.Query(offset, perPage) if err != nil { return logs, err } defer rows.Close() return buildLogList(rows) } type SQLAdminLogStore struct { create *sql.Stmt count *sql.Stmt getOffset *sql.Stmt } func NewAdminLogStore(acc *qgen.Accumulator) (*SQLAdminLogStore, error) { al := "administration_logs" cols := "action, elementID, elementType, ipaddress, actorID, doneAt, extra" return &SQLAdminLogStore{ create: acc.Insert(al).Columns(cols).Fields("?,?,?,?,?,UTC_TIMESTAMP(),?").Prepare(), count: acc.Count(al).Prepare(), getOffset: acc.Select(al).Columns(cols).Orderby("doneAt DESC").Limit("?,?").Prepare(), }, acc.FirstError() } // TODO: Make a store for this? func (s *SQLAdminLogStore) Create(action string, elementID int, elementType, ip string, actorID int) (err error) { return s.CreateExtra(action, elementID, elementType, ip, actorID, "") } func (s *SQLAdminLogStore) CreateExtra(action string, elementID int, elementType, ip string, actorID int, extra string) (err error) { _, err = s.create.Exec(action, elementID, elementType, ip, actorID, extra) return err } func (s *SQLAdminLogStore) Count() (count int) { err := s.count.QueryRow().Scan(&count) if err != nil { LogError(err) } return count } func (s *SQLAdminLogStore) GetOffset(offset, perPage int) (logs []LogItem, err error) { rows, err := s.getOffset.Query(offset, perPage) if err != nil { return logs, err } defer rows.Close() return buildLogList(rows) } ================================================ FILE: common/auth.go ================================================ /* * * Gosora Authentication Interface * Copyright Azareal 2017 - 2020 * */ package common import ( "crypto/sha256" "crypto/subtle" "database/sql" "encoding/hex" "errors" "net/http" "strconv" "strings" "github.com/Azareal/Gosora/common/gauth" qgen "github.com/Azareal/Gosora/query_gen" //"golang.org/x/crypto/argon2" "golang.org/x/crypto/bcrypt" ) // TODO: Write more authentication tests var Auth AuthInt const SaltLength int = 32 const SessionLength int = 80 // ErrMismatchedHashAndPassword is thrown whenever a hash doesn't match it's unhashed password var ErrMismatchedHashAndPassword = bcrypt.ErrMismatchedHashAndPassword // nolint var ErrHashNotExist = errors.New("We don't recognise that hashing algorithm") var ErrTooFewHashParams = errors.New("You haven't provided enough hash parameters") // ErrPasswordTooLong is silly, but we don't want bcrypt to bork on us var ErrPasswordTooLong = errors.New("The password you selected is too long") var ErrWrongPassword = errors.New("That's not the correct password.") var ErrBadMFAToken = errors.New("I'm not sure where you got that from, but that's not a valid 2FA token") var ErrWrongMFAToken = errors.New("That 2FA token isn't correct") var ErrNoMFAToken = errors.New("This user doesn't have 2FA setup") var ErrSecretError = errors.New("There was a glitch in the system. Please contact your local administrator.") var ErrNoUserByName = errors.New("We couldn't find an account with that username.") var DefaultHashAlgo = "bcrypt" // Override this in the configuration file, not here //func(realPassword string, password string, salt string) (err error) var CheckPasswordFuncs = map[string]func(string, string, string) error{ "bcrypt": BcryptCheckPassword, //"argon2": Argon2CheckPassword, } //func(password string) (hashedPassword string, salt string, err error) var GeneratePasswordFuncs = map[string]func(string) (string, string, error){ "bcrypt": BcryptGeneratePassword, //"argon2": Argon2GeneratePassword, } // TODO: Redirect 2b to bcrypt too? var HashPrefixes = map[string]string{ "$2a$": "bcrypt", //"argon2$": "argon2", } // AuthInt is the main authentication interface. type AuthInt interface { Authenticate(name, password string) (uid int, err error, requiresExtraAuth bool) ValidateMFAToken(mfaToken string, uid int) error Logout(w http.ResponseWriter, uid int) ForceLogout(uid int) error SetCookies(w http.ResponseWriter, uid int, session string) SetProvisionalCookies(w http.ResponseWriter, uid int, session, signedSession string) // To avoid logging someone in until they've passed the MFA check GetCookies(r *http.Request) (uid int, session string, err error) SessionCheck(w http.ResponseWriter, r *http.Request) (u *User, halt bool) CreateSession(uid int) (session string, err error) CreateProvisionalSession(uid int) (provSession, signedSession string, err error) // To avoid logging someone in until they've passed the MFA check } // DefaultAuth is the default authenticator used by Gosora, may be swapped with an alternate authenticator in some situations. E.g. To support LDAP. type DefaultAuth struct { login *sql.Stmt logout *sql.Stmt updateSession *sql.Stmt } // NewDefaultAuth is a factory for spitting out DefaultAuths func NewDefaultAuth() (*DefaultAuth, error) { acc := qgen.NewAcc() return &DefaultAuth{ login: acc.Select("users").Columns("uid, password, salt").Where("name = ?").Prepare(), logout: acc.Update("users").Set("session = ''").Where("uid = ?").Prepare(), updateSession: acc.Update("users").Set("session = ?").Where("uid = ?").Prepare(), }, acc.FirstError() } // Authenticate checks if a specific username and password is valid and returns the UID for the corresponding user, if so. Otherwise, a user safe error. // IF MFA is enabled, then pass it back a flag telling the caller that authentication isn't complete yet // TODO: Find a better way of handling errors we don't want to reach the user func (auth *DefaultAuth) Authenticate(name, password string) (uid int, err error, requiresExtraAuth bool) { var realPassword, salt string err = auth.login.QueryRow(name).Scan(&uid, &realPassword, &salt) if err == ErrNoRows { return 0, ErrNoUserByName, false } else if err != nil { LogError(err) return 0, ErrSecretError, false } err = CheckPassword(realPassword, password, salt) if err == ErrMismatchedHashAndPassword { return 0, ErrWrongPassword, false } else if err != nil { LogError(err) return 0, ErrSecretError, false } _, err = MFAstore.Get(uid) if err != sql.ErrNoRows && err != nil { LogError(err) return 0, ErrSecretError, false } if err != ErrNoRows { return uid, nil, true } return uid, nil, false } func (auth *DefaultAuth) ValidateMFAToken(mfaToken string, uid int) error { mfaItem, err := MFAstore.Get(uid) if err != sql.ErrNoRows && err != nil { LogError(err) return ErrSecretError } if err == ErrNoRows { return ErrNoMFAToken } ok, err := VerifyGAuthToken(mfaItem.Secret, mfaToken) if err != nil { return ErrBadMFAToken } if ok { return nil } for i, scratch := range mfaItem.Scratch { if subtle.ConstantTimeCompare([]byte(scratch), []byte(mfaToken)) == 1 { err = mfaItem.BurnScratch(i) if err != nil { LogError(err) return ErrSecretError } return nil } } return ErrWrongMFAToken } // ForceLogout logs the user out of every computer, not just the one they logged out of func (auth *DefaultAuth) ForceLogout(uid int) error { _, err := auth.logout.Exec(uid) if err != nil { LogError(err) return ErrSecretError } // Flush the user out of the cache if uc := Users.GetCache(); uc != nil { uc.Remove(uid) } return nil } func setCookie(w http.ResponseWriter, cookie *http.Cookie, sameSite string) { if v := cookie.String(); v != "" { switch sameSite { case "lax": v = v + "; SameSite=lax" case "strict": v = v + "; SameSite" } w.Header().Add("Set-Cookie", v) } } func deleteCookie(w http.ResponseWriter, cookie *http.Cookie) { cookie.MaxAge = -1 http.SetCookie(w, cookie) } // Logout logs you out of the computer you requested the logout for, but not the other computers you're logged in with func (auth *DefaultAuth) Logout(w http.ResponseWriter, _ int) { cookie := http.Cookie{Name: "uid", Value: "", Path: "/"} deleteCookie(w, &cookie) cookie = http.Cookie{Name: "session", Value: "", Path: "/"} deleteCookie(w, &cookie) } // TODO: Set the cookie domain // SetCookies sets the two cookies required for the current user to be recognised as a specific user in future requests func (auth *DefaultAuth) SetCookies(w http.ResponseWriter, uid int, session string) { cookie := http.Cookie{Name: "uid", Value: strconv.Itoa(uid), Path: "/", MaxAge: int(Year)} setCookie(w, &cookie, "lax") cookie = http.Cookie{Name: "session", Value: session, Path: "/", MaxAge: int(Year)} setCookie(w, &cookie, "lax") } // TODO: Set the cookie domain // SetProvisionalCookies sets the two cookies required for guests to be recognised as having passed the initial login but not having passed the additional checks (e.g. multi-factor authentication) func (auth *DefaultAuth) SetProvisionalCookies(w http.ResponseWriter, uid int, provSession, signedSession string) { cookie := http.Cookie{Name: "uid", Value: strconv.Itoa(uid), Path: "/", MaxAge: int(Year)} setCookie(w, &cookie, "lax") cookie = http.Cookie{Name: "provSession", Value: provSession, Path: "/", MaxAge: int(Year)} setCookie(w, &cookie, "lax") cookie = http.Cookie{Name: "signedSession", Value: signedSession, Path: "/", MaxAge: int(Year)} setCookie(w, &cookie, "lax") } // GetCookies fetches the current user's session cookies func (auth *DefaultAuth) GetCookies(r *http.Request) (uid int, session string, err error) { // Are there any session cookies..? cookie, err := r.Cookie("uid") if err != nil { return 0, "", err } uid, err = strconv.Atoi(cookie.Value) if err != nil { return 0, "", err } cookie, err = r.Cookie("session") if err != nil { return 0, "", err } return uid, cookie.Value, err } // SessionCheck checks if a user has session cookies and whether they're valid func (auth *DefaultAuth) SessionCheck(w http.ResponseWriter, r *http.Request) (user *User, halt bool) { uid, session, err := auth.GetCookies(r) if err != nil { return &GuestUser, false } // Is this session valid..? user, err = Users.Get(uid) if err == ErrNoRows { return &GuestUser, false } else if err != nil { InternalError(err, w, r) return &GuestUser, true } // We need to do a constant time compare, otherwise someone might be able to deduce the session character by character based on how long it takes to do the comparison. Change this at your own peril. if user.Session == "" || subtle.ConstantTimeCompare([]byte(session), []byte(user.Session)) != 1 { return &GuestUser, false } return user, false } // CreateSession generates a new session to allow a remote client to stay logged in as a specific user func (auth *DefaultAuth) CreateSession(uid int) (session string, err error) { session, err = GenerateSafeString(SessionLength) if err != nil { return "", err } _, err = auth.updateSession.Exec(session, uid) if err != nil { return "", err } // Flush the user data from the cache ucache := Users.GetCache() if ucache != nil { ucache.Remove(uid) } return session, nil } func (auth *DefaultAuth) CreateProvisionalSession(uid int) (provSession, signedSession string, err error) { provSession, err = GenerateSafeString(SessionLength) if err != nil { return "", "", err } h := sha256.New() h.Write([]byte(SessionSigningKeyBox.Load().(string))) h.Write([]byte(provSession)) h.Write([]byte(strconv.Itoa(uid))) return provSession, hex.EncodeToString(h.Sum(nil)), nil } func CheckPassword(realPassword, password, salt string) (err error) { blasted := strings.Split(realPassword, "$") prefix := blasted[0] if len(blasted) > 1 { prefix += "$" + blasted[1] + "$" } algo, ok := HashPrefixes[prefix] if !ok { return ErrHashNotExist } checker := CheckPasswordFuncs[algo] return checker(realPassword, password, salt) } func GeneratePassword(password string) (hash, salt string, err error) { gen, ok := GeneratePasswordFuncs[DefaultHashAlgo] if !ok { return "", "", ErrHashNotExist } return gen(password) } func BcryptCheckPassword(realPassword, password, salt string) (err error) { return bcrypt.CompareHashAndPassword([]byte(realPassword), []byte(password+salt)) } // Note: The salt is in the hash, therefore the salt parameter is blank func BcryptGeneratePassword(password string) (hash, salt string, err error) { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", "", err } return string(hashedPassword), salt, nil } /*const ( argon2Time uint32 = 3 argon2Memory uint32 = 32 * 1024 argon2Threads uint8 = 4 argon2KeyLen uint32 = 32 ) func Argon2CheckPassword(realPassword, password, salt string) (err error) { split := strings.Split(realPassword, "$") // TODO: Better validation if len(split) < 5 { return ErrTooFewHashParams } realKey, _ := base64.StdEncoding.DecodeString(split[len(split)-1]) time, _ := strconv.Atoi(split[1]) memory, _ := strconv.Atoi(split[2]) threads, _ := strconv.Atoi(split[3]) keyLen, _ := strconv.Atoi(split[4]) key := argon2.Key([]byte(password), []byte(salt), uint32(time), uint32(memory), uint8(threads), uint32(keyLen)) if subtle.ConstantTimeCompare(realKey, key) != 1 { return ErrMismatchedHashAndPassword } return nil } func Argon2GeneratePassword(password string) (hash, salt string, err error) { sbytes := make([]byte, SaltLength) _, err = rand.Read(sbytes) if err != nil { return "", "", err } key := argon2.Key([]byte(password), sbytes, argon2Time, argon2Memory, argon2Threads, argon2KeyLen) hash = base64.StdEncoding.EncodeToString(key) return fmt.Sprintf("argon2$%d%d%d%d%s%s", argon2Time, argon2Memory, argon2Threads, argon2KeyLen, salt, hash), string(sbytes), nil } */ // TODO: Test this with Google Authenticator proper func FriendlyGAuthSecret(secret string) (out string) { for i, char := range secret { out += string(char) if (i+1)%4 == 0 { out += " " } } return strings.TrimSpace(out) } func GenerateGAuthSecret() (string, error) { return GenerateStd32SafeString(14) } func VerifyGAuthToken(secret, token string) (bool, error) { trueToken, err := gauth.GetTOTPToken(secret) return subtle.ConstantTimeCompare([]byte(trueToken), []byte(token)) == 1, err } ================================================ FILE: common/cache.go ================================================ package common import "errors" // nolint // ErrCacheDesync is thrown whenever a piece of data, for instance, a user is out of sync with the database. Currently unused. var ErrCacheDesync = errors.New("The cache is out of sync with the database.") // TODO: A cross-server synchronisation mechanism // ErrStoreCapacityOverflow is thrown whenever a datastore reaches it's maximum hard capacity. I'm not sure if this error is actually used. It might be, we should check var ErrStoreCapacityOverflow = errors.New("This datastore has reached it's maximum capacity.") // nolint // nolint type DataStore interface { DirtyGet(id int) interface{} Get(id int) (interface{}, error) BypassGet(id int) (interface{}, error) //Count() int } // nolint type DataCache interface { CacheGet(id int) (interface{}, error) CacheGetUnsafe(id int) (interface{}, error) CacheSet(item interface{}) error CacheAdd(item interface{}) error CacheAddUnsafe(item interface{}) error CacheRemove(id int) error CacheRemoveUnsafe(id int) error Reload(id int) error Flush() Length() int SetCapacity(capacity int) GetCapacity() int } ================================================ FILE: common/common.go ================================================ /* * * Gosora Common Resources * Copyright Azareal 2018 - 2020 * */ package common // import "github.com/Azareal/Gosora/common" import ( "database/sql" "io" "log" "net" "net/http" "os" "runtime/debug" "strconv" "strings" "sync/atomic" "time" meta "github.com/Azareal/Gosora/common/meta" qgen "github.com/Azareal/Gosora/query_gen" ) var SoftwareVersion = Version{Major: 0, Minor: 3, Patch: 0, Tag: "dev"} var Meta meta.MetaStore // nolint I don't want to write comments for each of these o.o const Hour int = 60 * 60 const Day = Hour * 24 const Week = Day * 7 const Month = Day * 30 const Year = Day * 365 const Kilobyte int = 1024 const Megabyte = Kilobyte * 1024 const Gigabyte = Megabyte * 1024 const Terabyte = Gigabyte * 1024 const Petabyte = Terabyte * 1024 var StartTime time.Time var GzipStartEtag string var StartEtag string var TmplPtrMap = make(map[string]interface{}) // Anti-spam token with rotated key var JSTokenBox atomic.Value // TODO: Move this and some of these other globals somewhere else var SessionSigningKeyBox atomic.Value // For MFA to avoid hitting the database unneccessarily var OldSessionSigningKeyBox atomic.Value // Just in case we've signed with a key that's about to go stale so we don't annoy the user too much var IsDBDown int32 = 0 // 0 = false, 1 = true. this is value which should be manipulated with package atomic for representing whether the database is down so we don't spam the log with lots of redundant errors // ErrNoRows is an alias of sql.ErrNoRows, just in case we end up with non-database/sql datastores var ErrNoRows = sql.ErrNoRows //var StrSlicePool sync.Pool // ? - Make this more customisable? /*var ExternalSites = map[string]string{ "YT": "https://www.youtube.com/", }*/ // TODO: Make this more customisable var SpammyDomainBits = []string{"porn", "sex", "acup", "nude", "milf", "tits", "vape", "busty", "kink", "lingerie", "strapon", "problog", "fet", "xblog", "blogin", "blognetwork", "relayblog"} var Chrome, Firefox int // ! Temporary Hack for http push var SimpleBots []int // ! Temporary hack to stop semrush, ahrefs, python bots and other from wasting resources type StringList []string // ? - Should we allow users to upload .php or .go files? It could cause security issues. We could store them with a mangled extension to render them inert // TODO: Let admins manage this from the Control Panel // apng is commented out for now, as we have no way of re-encoding it into a smaller file var AllowedFileExts = StringList{ "png", "jpg", "jpe", "jpeg", "jif", "jfi", "jfif", "svg", "bmp", "gif", "tiff", "tif", "webp", "apng", "avif", "flif", "heif", "heic", "bpg", // images (encodable) + apng (browser support) + bpg + avif + flif + heif / heic "txt", "xml", "json", "yaml", "toml", "ini", "md", "html", "rtf", "js", "py", "rb", "css", "scss", "less", "eqcss", "pcss", "java", "ts", "cs", "c", "cc", "cpp", "cxx", "C", "c++", "h", "hh", "hpp", "hxx", "h++", "rs", "rlib", "htaccess", "gitignore", /*"go","php",*/ // text "wav", "mp3", "oga", "m4a", "flac", "ac3", "aac", "opus", // audio "mp4", "avi", "ogg", "ogv", "ogx", "wmv", "webm", "flv", "f4v", "xvid", "mov", "movie", "qt", // video "otf", "woff2", "woff", "ttf", "eot", // fonts "bz2", "zip", "zipx", "gz", "7z", "tar", "cab", "rar", "kgb", "pea", "xz", "zz", "tgz", "xpi", // archives "docx", "pdf", // documents } var ImageFileExts = StringList{ "png", "jpg", "jpe", "jpeg", "jif", "jfi", "jfif", "svg", "bmp", "gif", "tiff", "tif", "webp", /* "apng", "bpg", "avif", */ } var TextFileExts = StringList{ "txt", "xml", "json", "yaml", "toml", "ini", "md", "html", "rtf", "js", "py", "rb", "css", "scss", "less", "eqcss", "pcss", "java", "ts", "cs", "c", "cc", "cpp", "cxx", "C", "c++", "h", "hh", "hpp", "hxx", "h++", "rs", "rlib", "htaccess", "gitignore", /*"go","php",*/ } var VideoFileExts = StringList{ "mp4", "avi", "ogg", "ogv", "ogx", "wmv", "webm", "flv", "f4v", "xvid", "mov", "movie", "qt", } var WebVideoFileExts = StringList{ "mp4", "avi", "ogg", "ogv", "webm", } var WebAudioFileExts = StringList{ "wav", "mp3", "oga", "m4a", "flac", } var ArchiveFileExts = StringList{ "bz2", "zip", "zipx", "gz", "7z", "tar", "cab", "rar", "kgb", "pea", "xz", "zz", "tgz", "xpi", } var ExecutableFileExts = StringList{ "exe", "jar", "phar", "shar", "iso", "apk", "deb", } func init() { JSTokenBox.Store("") SessionSigningKeyBox.Store("") OldSessionSigningKeyBox.Store("") } // TODO: Write a test for this func (sl StringList) Contains(needle string) bool { for _, it := range sl { if it == needle { return true } } return false } /*var DbTables []string var TableToID = make(map[string]int) var IDToTable = make(map[int]string) func InitTables(acc *qgen.Accumulator) error { stmt := acc.Select("tables").Columns("id,name").Prepare() if e := acc.FirstError(); e != nil { return e } return eachall(stmt, func(r *sql.Rows) error { var id int var name string if e := r.Scan(&id, &name); e != nil { return e } TableToID[name] = id IDToTable[id] = name return nil }) }*/ type dbInits []func(acc *qgen.Accumulator) error var DbInits dbInits func (inits dbInits) Run() error { for _, i := range inits { if e := i(qgen.NewAcc()); e != nil { return e } } return nil } func (inits dbInits) Add(i ...func(acc *qgen.Accumulator) error) { DbInits = dbInits(append(DbInits, i...)) } // TODO: Add a graceful shutdown function func StoppedServer(msg ...interface{}) { //log.Print("stopped server") StopServerChan <- msg } var StopServerChan = make(chan []interface{}) var LogWriter = io.MultiWriter(os.Stdout) var ErrLogWriter = io.MultiWriter(os.Stderr) var ErrLogger = log.New(os.Stderr, "", log.LstdFlags) func DebugDetail(args ...interface{}) { if Dev.SuperDebug { log.Print(args...) } } func DebugDetailf(str string, args ...interface{}) { if Dev.SuperDebug { log.Printf(str, args...) } } func DebugLog(args ...interface{}) { if Dev.DebugMode { log.Print(args...) } } func DebugLogf(str string, args ...interface{}) { if Dev.DebugMode { log.Printf(str, args...) } } func Log(args ...interface{}) { log.Print(args...) } func Logf(str string, args ...interface{}) { log.Printf(str, args...) } func Err(args ...interface{}) { ErrLogger.Print(args...) } func Count(stmt *sql.Stmt) (count int) { e := stmt.QueryRow().Scan(&count) if e != nil { LogError(e) } return count } func Countf(stmt *sql.Stmt, args ...interface{}) (count int) { e := stmt.QueryRow(args...).Scan(&count) if e != nil { LogError(e) } return count } func Createf(stmt *sql.Stmt, args ...interface{}) (id int, e error) { res, e := stmt.Exec(args...) if e != nil { return 0, e } id64, e := res.LastInsertId() return int(id64), e } func eachall(stmt *sql.Stmt, f func(r *sql.Rows) error) error { rows, e := stmt.Query() if e != nil { return e } defer rows.Close() for rows.Next() { if e := f(rows); e != nil { return e } } return rows.Err() } var qcache = []string{0: "?", 1: "?,?", 2: "?,?,?", 3: "?,?,?,?", 4: "?,?,?,?,?", 5: "?,?,?,?,?,?", 6: "?,?,?,?,?,?,?", 7: "?,?,?,?,?,?,?,?", 8: "?,?,?,?,?,?,?,?,?"} func inqbuild(ids []int) ([]interface{}, string) { if len(ids) < 8 { idList := make([]interface{}, len(ids)) for i, id := range ids { idList[i] = strconv.Itoa(id) } return idList, qcache[len(ids)-1] } var sb strings.Builder sb.Grow((len(ids) * 2) - 1) idList := make([]interface{}, len(ids)) for i, id := range ids { idList[i] = strconv.Itoa(id) if i == 0 { sb.WriteRune('?') } else { sb.WriteString(",?") } } return idList, sb.String() } func inqbuild2(count int) string { if count <= 8 { return qcache[count-1] } var sb strings.Builder sb.Grow((count * 2) - 1) for i := 0; i < count; i++ { if i == 0 { sb.WriteRune('?') } else { sb.WriteString(",?") } } return sb.String() } func inqbuildstr(strs []string) ([]interface{}, string) { if len(strs) < 8 { idList := make([]interface{}, len(strs)) for i, id := range strs { idList[i] = id } return idList, qcache[len(strs)-1] } var sb strings.Builder sb.Grow((len(strs) * 2) - 1) idList := make([]interface{}, len(strs)) for i, id := range strs { idList[i] = id if i == 0 { sb.WriteRune('?') } else { sb.WriteString(",?") } } return idList, sb.String() } var ConnWatch = &ConnWatcher{} type ConnWatcher struct { n int64 } func (cw *ConnWatcher) StateChange(conn net.Conn, state http.ConnState) { switch state { case http.StateNew: atomic.AddInt64(&cw.n, 1) case http.StateHijacked, http.StateClosed: atomic.AddInt64(&cw.n, -1) } } func (cw *ConnWatcher) Count() int { return int(atomic.LoadInt64(&cw.n)) } func EatPanics() { if r := recover(); r != nil { log.Print(r) debug.PrintStack() log.Fatal("Fatal error.") } } ================================================ FILE: common/common_easyjson.tgo ================================================ // Code generated by easyjson for marshaling/unmarshaling. DO NOT EDIT. package common import ( json "encoding/json" easyjson "github.com/mailru/easyjson" jlexer "github.com/mailru/easyjson/jlexer" jwriter "github.com/mailru/easyjson/jwriter" ) // suppress unused package warning var ( _ *json.RawMessage _ *jlexer.Lexer _ *jwriter.Writer _ easyjson.Marshaler ) func easyjsonC803d3e7DecodeGithubComAzarealGosoraCommon(in *jlexer.Lexer, out *WsTopicList) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { in.Consumed() } in.Skip() return } in.Delim('{') for !in.IsDelim('}') { key := in.UnsafeString() in.WantColon() if in.IsNull() { in.Skip() in.WantComma() continue } switch key { case "Topics": if in.IsNull() { in.Skip() out.Topics = nil } else { in.Delim('[') if out.Topics == nil { if !in.IsDelim(']') { out.Topics = make([]*WsTopicsRow, 0, 8) } else { out.Topics = []*WsTopicsRow{} } } else { out.Topics = (out.Topics)[:0] } for !in.IsDelim(']') { var v1 *WsTopicsRow if in.IsNull() { in.Skip() v1 = nil } else { if v1 == nil { v1 = new(WsTopicsRow) } easyjsonC803d3e7DecodeGithubComAzarealGosoraCommon1(in, v1) } out.Topics = append(out.Topics, v1) in.WantComma() } in.Delim(']') } case "LastPage": out.LastPage = int(in.Int()) case "LastUpdate": out.LastUpdate = int64(in.Int64()) default: in.SkipRecursive() } in.WantComma() } in.Delim('}') if isTopLevel { in.Consumed() } } func easyjsonC803d3e7EncodeGithubComAzarealGosoraCommon(out *jwriter.Writer, in WsTopicList) { out.RawByte('{') first := true _ = first { const prefix string = ",\"Topics\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } if in.Topics == nil && (out.Flags&jwriter.NilSliceAsEmpty) == 0 { out.RawString("null") } else { out.RawByte('[') for v2, v3 := range in.Topics { if v2 > 0 { out.RawByte(',') } if v3 == nil { out.RawString("null") } else { easyjsonC803d3e7EncodeGithubComAzarealGosoraCommon1(out, *v3) } } out.RawByte(']') } } { const prefix string = ",\"LastPage\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.LastPage)) } { const prefix string = ",\"LastUpdate\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int64(int64(in.LastUpdate)) } out.RawByte('}') } // MarshalJSON supports json.Marshaler interface func (v WsTopicList) MarshalJSON() ([]byte, error) { w := jwriter.Writer{} easyjsonC803d3e7EncodeGithubComAzarealGosoraCommon(&w, v) return w.Buffer.BuildBytes(), w.Error } // MarshalEasyJSON supports easyjson.Marshaler interface func (v WsTopicList) MarshalEasyJSON(w *jwriter.Writer) { easyjsonC803d3e7EncodeGithubComAzarealGosoraCommon(w, v) } // UnmarshalJSON supports json.Unmarshaler interface func (v *WsTopicList) UnmarshalJSON(data []byte) error { r := jlexer.Lexer{Data: data} easyjsonC803d3e7DecodeGithubComAzarealGosoraCommon(&r, v) return r.Error() } // UnmarshalEasyJSON supports easyjson.Unmarshaler interface func (v *WsTopicList) UnmarshalEasyJSON(l *jlexer.Lexer) { easyjsonC803d3e7DecodeGithubComAzarealGosoraCommon(l, v) } func easyjsonC803d3e7DecodeGithubComAzarealGosoraCommon1(in *jlexer.Lexer, out *WsTopicsRow) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { in.Consumed() } in.Skip() return } in.Delim('{') for !in.IsDelim('}') { key := in.UnsafeString() in.WantColon() if in.IsNull() { in.Skip() in.WantComma() continue } switch key { case "ID": out.ID = int(in.Int()) case "Link": out.Link = string(in.String()) case "Title": out.Title = string(in.String()) case "CreatedBy": out.CreatedBy = int(in.Int()) case "IsClosed": out.IsClosed = bool(in.Bool()) case "Sticky": out.Sticky = bool(in.Bool()) case "CreatedAt": if data := in.Raw(); in.Ok() { in.AddError((out.CreatedAt).UnmarshalJSON(data)) } case "LastReplyAt": if data := in.Raw(); in.Ok() { in.AddError((out.LastReplyAt).UnmarshalJSON(data)) } case "RelativeLastReplyAt": out.RelativeLastReplyAt = string(in.String()) case "LastReplyBy": out.LastReplyBy = int(in.Int()) case "LastReplyID": out.LastReplyID = int(in.Int()) case "ParentID": out.ParentID = int(in.Int()) case "ViewCount": out.ViewCount = int64(in.Int64()) case "PostCount": out.PostCount = int(in.Int()) case "LikeCount": out.LikeCount = int(in.Int()) case "AttachCount": out.AttachCount = int(in.Int()) case "ClassName": out.ClassName = string(in.String()) case "Creator": if in.IsNull() { in.Skip() out.Creator = nil } else { if out.Creator == nil { out.Creator = new(WsJSONUser) } easyjsonC803d3e7DecodeGithubComAzarealGosoraCommon2(in, out.Creator) } case "LastUser": if in.IsNull() { in.Skip() out.LastUser = nil } else { if out.LastUser == nil { out.LastUser = new(WsJSONUser) } easyjsonC803d3e7DecodeGithubComAzarealGosoraCommon2(in, out.LastUser) } case "ForumName": out.ForumName = string(in.String()) case "ForumLink": out.ForumLink = string(in.String()) default: in.SkipRecursive() } in.WantComma() } in.Delim('}') if isTopLevel { in.Consumed() } } func easyjsonC803d3e7EncodeGithubComAzarealGosoraCommon1(out *jwriter.Writer, in WsTopicsRow) { out.RawByte('{') first := true _ = first { const prefix string = ",\"ID\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.ID)) } { const prefix string = ",\"Link\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.String(string(in.Link)) } { const prefix string = ",\"Title\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.String(string(in.Title)) } { const prefix string = ",\"CreatedBy\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.CreatedBy)) } { const prefix string = ",\"IsClosed\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Bool(bool(in.IsClosed)) } { const prefix string = ",\"Sticky\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Bool(bool(in.Sticky)) } { const prefix string = ",\"CreatedAt\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Raw((in.CreatedAt).MarshalJSON()) } { const prefix string = ",\"LastReplyAt\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Raw((in.LastReplyAt).MarshalJSON()) } { const prefix string = ",\"RelativeLastReplyAt\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.String(string(in.RelativeLastReplyAt)) } { const prefix string = ",\"LastReplyBy\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.LastReplyBy)) } { const prefix string = ",\"LastReplyID\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.LastReplyID)) } { const prefix string = ",\"ParentID\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.ParentID)) } { const prefix string = ",\"ViewCount\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int64(int64(in.ViewCount)) } { const prefix string = ",\"PostCount\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.PostCount)) } { const prefix string = ",\"LikeCount\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.LikeCount)) } { const prefix string = ",\"AttachCount\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.AttachCount)) } { const prefix string = ",\"ClassName\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.String(string(in.ClassName)) } { const prefix string = ",\"Creator\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } if in.Creator == nil { out.RawString("null") } else { easyjsonC803d3e7EncodeGithubComAzarealGosoraCommon2(out, *in.Creator) } } { const prefix string = ",\"LastUser\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } if in.LastUser == nil { out.RawString("null") } else { easyjsonC803d3e7EncodeGithubComAzarealGosoraCommon2(out, *in.LastUser) } } { const prefix string = ",\"ForumName\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.String(string(in.ForumName)) } { const prefix string = ",\"ForumLink\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.String(string(in.ForumLink)) } out.RawByte('}') } func easyjsonC803d3e7DecodeGithubComAzarealGosoraCommon2(in *jlexer.Lexer, out *WsJSONUser) { isTopLevel := in.IsStart() if in.IsNull() { if isTopLevel { in.Consumed() } in.Skip() return } in.Delim('{') for !in.IsDelim('}') { key := in.UnsafeString() in.WantColon() if in.IsNull() { in.Skip() in.WantComma() continue } switch key { case "ID": out.ID = int(in.Int()) case "Link": out.Link = string(in.String()) case "Name": out.Name = string(in.String()) case "Group": out.Group = int(in.Int()) case "IsMod": out.IsMod = bool(in.Bool()) case "Avatar": out.Avatar = string(in.String()) case "MicroAvatar": out.MicroAvatar = string(in.String()) case "Level": out.Level = int(in.Int()) case "Score": out.Score = int(in.Int()) case "Liked": out.Liked = int(in.Int()) default: in.SkipRecursive() } in.WantComma() } in.Delim('}') if isTopLevel { in.Consumed() } } func easyjsonC803d3e7EncodeGithubComAzarealGosoraCommon2(out *jwriter.Writer, in WsJSONUser) { out.RawByte('{') first := true _ = first { const prefix string = ",\"ID\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.ID)) } { const prefix string = ",\"Link\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.String(string(in.Link)) } { const prefix string = ",\"Name\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.String(string(in.Name)) } { const prefix string = ",\"Group\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.Group)) } { const prefix string = ",\"IsMod\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Bool(bool(in.IsMod)) } { const prefix string = ",\"Avatar\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.String(string(in.Avatar)) } { const prefix string = ",\"MicroAvatar\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.String(string(in.MicroAvatar)) } { const prefix string = ",\"Level\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.Level)) } { const prefix string = ",\"Score\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.Score)) } { const prefix string = ",\"Liked\":" if first { first = false out.RawString(prefix[1:]) } else { out.RawString(prefix) } out.Int(int(in.Liked)) } out.RawByte('}') } ================================================ FILE: common/conversations.go ================================================ package common import ( "errors" "time" //"log" "database/sql" "strconv" qgen "github.com/Azareal/Gosora/query_gen" ) var Convos ConversationStore var convoStmts ConvoStmts type ConvoStmts struct { fetchPost *sql.Stmt getPosts *sql.Stmt countPosts *sql.Stmt edit *sql.Stmt create *sql.Stmt delete *sql.Stmt has *sql.Stmt editPost *sql.Stmt createPost *sql.Stmt deletePost *sql.Stmt getUsers *sql.Stmt } func init() { DbInits.Add(func(acc *qgen.Accumulator) error { cpo := "conversations_posts" convoStmts = ConvoStmts{ fetchPost: acc.Select(cpo).Columns("cid,body,post,createdBy").Where("pid=?").Prepare(), getPosts: acc.Select(cpo).Columns("pid,body,post,createdBy").Where("cid=?").Limit("?,?").Prepare(), countPosts: acc.Count(cpo).Where("cid=?").Prepare(), edit: acc.Update("conversations").Set("lastReplyBy=?,lastReplyAt=?").Where("cid=?").Prepare(), create: acc.Insert("conversations").Columns("createdAt,lastReplyAt").Fields("UTC_TIMESTAMP(),UTC_TIMESTAMP()").Prepare(), has: acc.Count("conversations_participants").Where("uid=? AND cid=?").Prepare(), editPost: acc.Update(cpo).Set("body=?,post=?").Where("pid=?").Prepare(), createPost: acc.Insert(cpo).Columns("cid,body,post,createdBy").Fields("?,?,?,?").Prepare(), deletePost: acc.Delete(cpo).Where("pid=?").Prepare(), getUsers: acc.Select("conversations_participants").Columns("uid").Where("cid=?").Prepare(), } return acc.FirstError() }) } type Conversation struct { ID int Link string CreatedBy int CreatedAt time.Time LastReplyBy int LastReplyAt time.Time } func (co *Conversation) Posts(offset, itemsPerPage int) (posts []*ConversationPost, err error) { rows, err := convoStmts.getPosts.Query(co.ID, offset, itemsPerPage) if err != nil { return nil, err } defer rows.Close() for rows.Next() { p := &ConversationPost{CID: co.ID} err := rows.Scan(&p.ID, &p.Body, &p.Post, &p.CreatedBy) if err != nil { return nil, err } p, err = ConvoPostProcess.OnLoad(p) if err != nil { return nil, err } posts = append(posts, p) } return posts, rows.Err() } func (co *Conversation) PostsCount() (count int) { return Countf(convoStmts.countPosts, co.ID) } func (co *Conversation) Uids() (ids []int, err error) { rows, e := convoStmts.getUsers.Query(co.ID) if e != nil { return nil, e } defer rows.Close() for rows.Next() { var id int if e := rows.Scan(&id); e != nil { return nil, e } ids = append(ids, id) } return ids, rows.Err() } func (co *Conversation) Has(uid int) (in bool) { return Countf(convoStmts.has, uid, co.ID) > 0 } func (co *Conversation) Update() error { _, err := convoStmts.edit.Exec(co.CreatedAt, co.LastReplyBy, co.LastReplyAt, co.ID) return err } func (co *Conversation) Create() (int, error) { res, err := convoStmts.create.Exec() if err != nil { return 0, err } lastID, err := res.LastInsertId() return int(lastID), err } func BuildConvoURL(coid int) string { return "/user/convo/" + strconv.Itoa(coid) } type ConversationExtra struct { *Conversation Users []*User } type ConversationStore interface { Get(id int) (*Conversation, error) GetUser(uid, offset int) (cos []*Conversation, err error) GetUserExtra(uid, offset int) (cos []*ConversationExtra, err error) GetUserCount(uid int) (count int) Delete(id int) error Count() (count int) Create(content string, createdBy int, participants []int) (int, error) } type DefaultConversationStore struct { get *sql.Stmt getUser *sql.Stmt getUserCount *sql.Stmt delete *sql.Stmt deletePosts *sql.Stmt deleteParticipants *sql.Stmt create *sql.Stmt addParticipant *sql.Stmt count *sql.Stmt } func NewDefaultConversationStore(acc *qgen.Accumulator) (*DefaultConversationStore, error) { co := "conversations" return &DefaultConversationStore{ get: acc.Select(co).Columns("createdBy,createdAt,lastReplyBy,lastReplyAt").Where("cid=?").Prepare(), getUser: acc.SimpleInnerJoin("conversations_participants AS cp", "conversations AS c", "cp.cid, c.createdBy, c.createdAt, c.lastReplyBy, c.lastReplyAt", "cp.cid=c.cid", "cp.uid=?", "c.lastReplyAt DESC, c.createdAt DESC, c.cid DESC", "?,?"), getUserCount: acc.Count("conversations_participants").Where("uid=?").Prepare(), delete: acc.Delete(co).Where("cid=?").Prepare(), deletePosts: acc.Delete("conversations_posts").Where("cid=?").Prepare(), deleteParticipants: acc.Delete("conversations_participants").Where("cid=?").Prepare(), create: acc.Insert(co).Columns("createdBy,createdAt,lastReplyBy,lastReplyAt").Fields("?,UTC_TIMESTAMP(),?,UTC_TIMESTAMP()").Prepare(), addParticipant: acc.Insert("conversations_participants").Columns("uid,cid").Fields("?,?").Prepare(), count: acc.Count(co).Prepare(), }, acc.FirstError() } func (s *DefaultConversationStore) Get(id int) (*Conversation, error) { co := &Conversation{ID: id} err := s.get.QueryRow(id).Scan(&co.CreatedBy, &co.CreatedAt, &co.LastReplyBy, &co.LastReplyAt) co.Link = BuildConvoURL(co.ID) return co, err } func (s *DefaultConversationStore) GetUser(uid, offset int) (cos []*Conversation, err error) { rows, err := s.getUser.Query(uid, offset, Config.ItemsPerPage) if err != nil { return nil, err } defer rows.Close() for rows.Next() { co := &Conversation{} err := rows.Scan(&co.ID, &co.CreatedBy, &co.CreatedAt, &co.LastReplyBy, &co.LastReplyAt) if err != nil { return nil, err } co.Link = BuildConvoURL(co.ID) cos = append(cos, co) } err = rows.Err() if err != nil { return nil, err } if len(cos) == 0 { err = sql.ErrNoRows } return cos, err } func (s *DefaultConversationStore) GetUserExtra(uid, offset int) (cos []*ConversationExtra, err error) { raw, err := s.GetUser(uid, offset) if err != nil { return nil, err } //log.Printf("raw: %+v\n", raw) if len(raw) == 1 { //log.Print("r0b2") uids, err := raw[0].Uids() if err != nil { return nil, err } //log.Println("r1b2") umap, err := Users.BulkGetMap(uids) if err != nil { return nil, err } //log.Println("r2b2") users := make([]*User, len(umap)) var i int for _, user := range umap { users[i] = user i++ } return []*ConversationExtra{{raw[0], users}}, nil } //log.Println("1") cmap := make(map[int]*ConversationExtra, len(raw)) for _, co := range raw { cmap[co.ID] = &ConversationExtra{co, nil} } // TODO: Use inqbuild for this or a similar function var q string idList := make([]interface{}, len(raw)) for i, co := range raw { if i == 0 { q = "?" } else { q += ",?" } idList[i] = strconv.Itoa(co.ID) } rows, err := qgen.NewAcc().Select("conversations_participants").Columns("uid,cid").Where("cid IN(" + q + ")").Query(idList...) if err != nil { return nil, err } defer rows.Close() //log.Println("2") idmap := make(map[int][]int) // cid: []uid puidmap := make(map[int]struct{}) for rows.Next() { var uid, cid int err := rows.Scan(&uid, &cid) if err != nil { return nil, err } idmap[cid] = append(idmap[cid], uid) puidmap[uid] = struct{}{} } if err = rows.Err(); err != nil { return nil, err } //log.Println("3") //log.Printf("idmap: %+v\n", idmap) //log.Printf("puidmap: %+v\n",puidmap) puids := make([]int, len(puidmap)) var i int for puid, _ := range puidmap { puids[i] = puid i++ } umap, err := Users.BulkGetMap(puids) if err != nil { return nil, err } //log.Println("4") //log.Printf("umap: %+v\n", umap) for cid, uids := range idmap { co := cmap[cid] for _, uid := range uids { co.Users = append(co.Users, umap[uid]) } //log.Printf("co.Conversation: %+v\n", co.Conversation) //log.Printf("co.Users: %+v\n", co.Users) cmap[cid] = co } //log.Printf("cmap: %+v\n", cmap) for _, ra := range raw { cos = append(cos, cmap[ra.ID]) } //log.Printf("cos: %+v\n", cos) return cos, rows.Err() } func (s *DefaultConversationStore) GetUserCount(uid int) (count int) { err := s.getUserCount.QueryRow(uid).Scan(&count) if err != nil { LogError(err) } return count } // TODO: Use a foreign key or transaction func (s *DefaultConversationStore) Delete(id int) error { _, err := s.delete.Exec(id) if err != nil { return err } _, err = s.deletePosts.Exec(id) if err != nil { return err } _, err = s.deleteParticipants.Exec(id) return err } func (s *DefaultConversationStore) Create(content string, createdBy int, participants []int) (int, error) { if len(participants) == 0 { return 0, errors.New("no participants set") } res, err := s.create.Exec(createdBy, createdBy) if err != nil { return 0, err } lastID, err := res.LastInsertId() if err != nil { return 0, err } post := &ConversationPost{CID: int(lastID), Body: content, CreatedBy: createdBy} _, err = post.Create() if err != nil { return 0, err } for _, p := range participants { if p == createdBy { continue } _, err := s.addParticipant.Exec(p, lastID) if err != nil { return 0, err } } _, err = s.addParticipant.Exec(createdBy, lastID) if err != nil { return 0, err } return int(lastID), err } // Count returns the total number of topics on these forums func (s *DefaultConversationStore) Count() (count int) { err := s.count.QueryRow().Scan(&count) if err != nil { LogError(err) } return count } ================================================ FILE: common/convos_posts.go ================================================ package common import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/hex" "io" ) var ConvoPostProcess ConvoPostProcessor = NewDefaultConvoPostProcessor() type ConvoPostProcessor interface { OnLoad(co *ConversationPost) (*ConversationPost, error) OnSave(co *ConversationPost) (*ConversationPost, error) } type DefaultConvoPostProcessor struct { } func NewDefaultConvoPostProcessor() *DefaultConvoPostProcessor { return &DefaultConvoPostProcessor{} } func (pr *DefaultConvoPostProcessor) OnLoad(co *ConversationPost) (*ConversationPost, error) { return co, nil } func (pr *DefaultConvoPostProcessor) OnSave(co *ConversationPost) (*ConversationPost, error) { return co, nil } type AesConvoPostProcessor struct { } func NewAesConvoPostProcessor() *AesConvoPostProcessor { return &AesConvoPostProcessor{} } func (pr *AesConvoPostProcessor) OnLoad(co *ConversationPost) (*ConversationPost, error) { if co.Post != "aes" { return co, nil } key, _ := hex.DecodeString(Config.ConvoKey) ciphertext, err := hex.DecodeString(co.Body) if err != nil { return nil, err } block, err := aes.NewCipher(key) if err != nil { return nil, err } aesgcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonceSize := aesgcm.NonceSize() if len(ciphertext) < nonceSize { return nil, err } nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:] plaintext, err := aesgcm.Open(nil, nonce, ciphertext, nil) if err != nil { return nil, err } lco := *co lco.Body = string(plaintext) return &lco, nil } func (pr *AesConvoPostProcessor) OnSave(co *ConversationPost) (*ConversationPost, error) { key, _ := hex.DecodeString(Config.ConvoKey) block, err := aes.NewCipher(key) if err != nil { return nil, err } nonce := make([]byte, 12) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } aesgcm, err := cipher.NewGCM(block) if err != nil { return nil, err } ciphertext := aesgcm.Seal(nil, nonce, []byte(co.Body), nil) lco := *co lco.Body = hex.EncodeToString(ciphertext) lco.Post = "aes" return &lco, nil } type ConversationPost struct { ID int CID int Body string Post string // aes, '' CreatedBy int } // TODO: Should we run OnLoad on this? Or maybe add a FetchMeta method to avoid having to decode the message when it's not necessary? func (co *ConversationPost) Fetch() error { return convoStmts.fetchPost.QueryRow(co.ID).Scan(&co.CID, &co.Body, &co.Post, &co.CreatedBy) } func (co *ConversationPost) Update() error { lco, err := ConvoPostProcess.OnSave(co) if err != nil { return err } //GetHookTable().VhookNoRet("convo_post_update", lco) _, err = convoStmts.editPost.Exec(lco.Body, lco.Post, lco.ID) return err } func (co *ConversationPost) Create() (int, error) { lco, err := ConvoPostProcess.OnSave(co) if err != nil { return 0, err } //GetHookTable().VhookNoRet("convo_post_create", lco) res, err := convoStmts.createPost.Exec(lco.CID, lco.Body, lco.Post, lco.CreatedBy) if err != nil { return 0, err } lastID, err := res.LastInsertId() return int(lastID), err } func (co *ConversationPost) Delete() error { _, err := convoStmts.deletePost.Exec(co.ID) return err } ================================================ FILE: common/counters/agents.go ================================================ package counters import ( "database/sql" "sync/atomic" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" "github.com/pkg/errors" ) var AgentViewCounter *DefaultAgentViewCounter type DefaultAgentViewCounter struct { buckets []int64 //[AgentID]count insert *sql.Stmt } func NewDefaultAgentViewCounter(acc *qgen.Accumulator) (*DefaultAgentViewCounter, error) { co := &DefaultAgentViewCounter{ buckets: make([]int64, len(agentMapEnum)), insert: acc.Insert("viewchunks_agents").Columns("count,createdAt,browser").Fields("?,UTC_TIMESTAMP(),?").Prepare(), } c.Tasks.FifteenMin.Add(co.Tick) //c.Tasks.Sec.Add(co.Tick) c.Tasks.Shutdown.Add(co.Tick) return co, acc.FirstError() } func (co *DefaultAgentViewCounter) Tick() error { for id, _ := range co.buckets { count := atomic.SwapInt64(&co.buckets[id], 0) e := co.insertChunk(count, id) // TODO: Bulk insert for speed? if e != nil { return errors.Wrap(errors.WithStack(e), "agent counter") } } return nil } func (co *DefaultAgentViewCounter) insertChunk(count int64, agent int) error { if count == 0 { return nil } agentName := reverseAgentMapEnum[agent] c.DebugLogf("Inserting a vchunk with a count of %d for agent %s (%d)", count, agentName, agent) _, e := co.insert.Exec(count, agentName) return e } func (co *DefaultAgentViewCounter) Bump(agent int) { // TODO: Test this check c.DebugDetail("buckets ", agent, ": ", co.buckets[agent]) if len(co.buckets) <= agent || agent < 0 { return } atomic.AddInt64(&co.buckets[agent], 1) } ================================================ FILE: common/counters/common.go ================================================ package counters import "sync" // TODO: Make a neater API for this var routeMapEnum map[string]int var reverseRouteMapEnum map[int]string func SetRouteMapEnum(rme map[string]int) { routeMapEnum = rme } func SetReverseRouteMapEnum(rrme map[int]string) { reverseRouteMapEnum = rrme } var agentMapEnum map[string]int var reverseAgentMapEnum map[int]string func SetAgentMapEnum(ame map[string]int) { agentMapEnum = ame } func SetReverseAgentMapEnum(rame map[int]string) { reverseAgentMapEnum = rame } var osMapEnum map[string]int var reverseOSMapEnum map[int]string func SetOSMapEnum(osme map[string]int) { osMapEnum = osme } func SetReverseOSMapEnum(rosme map[int]string) { reverseOSMapEnum = rosme } type RWMutexCounterBucket struct { counter int sync.RWMutex } type MutexCounterBucket struct { counter int sync.Mutex } type MutexCounter64Bucket struct { counter int64 sync.Mutex } ================================================ FILE: common/counters/forums.go ================================================ package counters import ( "database/sql" "sync" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" "github.com/pkg/errors" ) var ForumViewCounter *DefaultForumViewCounter // TODO: Unload forum counters without any views over the past 15 minutes, if the admin has configured the forumstore with a cap and it's been hit? // Forums can be reloaded from the database at any time, so we want to keep the counters separate from them type DefaultForumViewCounter struct { oddMap map[int]*RWMutexCounterBucket // map[fid]struct{counter,sync.RWMutex} evenMap map[int]*RWMutexCounterBucket oddLock sync.RWMutex evenLock sync.RWMutex insert *sql.Stmt } func NewDefaultForumViewCounter() (*DefaultForumViewCounter, error) { acc := qgen.NewAcc() co := &DefaultForumViewCounter{ oddMap: make(map[int]*RWMutexCounterBucket), evenMap: make(map[int]*RWMutexCounterBucket), insert: acc.Insert("viewchunks_forums").Columns("count,createdAt,forum").Fields("?,UTC_TIMESTAMP(),?").Prepare(), } c.Tasks.FifteenMin.Add(co.Tick) // There could be a lot of routes, so we don't want to be running this every second //c.Tasks.Sec.Add(co.Tick) c.Tasks.Shutdown.Add(co.Tick) return co, acc.FirstError() } func (co *DefaultForumViewCounter) Tick() error { cLoop := func(l *sync.RWMutex, m map[int]*RWMutexCounterBucket) error { l.RLock() for fid, f := range m { l.RUnlock() var count int f.RLock() count = f.counter f.RUnlock() // TODO: Only delete the bucket when it's zero to avoid hitting popular forums? l.Lock() delete(m, fid) l.Unlock() e := co.insertChunk(count, fid) if e != nil { return errors.Wrap(errors.WithStack(e), "forum counter") } l.RLock() } l.RUnlock() return nil } e := cLoop(&co.oddLock, co.oddMap) if e != nil { return e } return cLoop(&co.evenLock, co.evenMap) } func (co *DefaultForumViewCounter) insertChunk(count, forum int) error { if count == 0 { return nil } c.DebugLogf("Inserting a vchunk with a count of %d for forum %d", count, forum) _, e := co.insert.Exec(count, forum) return e } func (co *DefaultForumViewCounter) Bump(fid int) { // Is the ID even? if fid%2 == 0 { co.evenLock.RLock() f, ok := co.evenMap[fid] co.evenLock.RUnlock() if ok { f.Lock() f.counter++ f.Unlock() } else { co.evenLock.Lock() co.evenMap[fid] = &RWMutexCounterBucket{counter: 1} co.evenLock.Unlock() } return } co.oddLock.RLock() f, ok := co.oddMap[fid] co.oddLock.RUnlock() if ok { f.Lock() f.counter++ f.Unlock() } else { co.oddLock.Lock() co.oddMap[fid] = &RWMutexCounterBucket{counter: 1} co.oddLock.Unlock() } } // TODO: Add a forum counter backed by two maps which grow as forums are created but never shrinks ================================================ FILE: common/counters/langs.go ================================================ package counters import ( "database/sql" "sync/atomic" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" "github.com/pkg/errors" ) var LangViewCounter *DefaultLangViewCounter var langCodes = []string{ "unknown", "", "af", "ar", "az", "be", "bg", "bs", "ca", "cs", "cy", "da", "de", "dv", "el", "en", "eo", "es", "et", "eu", "fa", "fi", "fo", "fr", "gl", "gu", "he", "hi", "hr", "hu", "hy", "id", "is", "it", "ja", "ka", "kk", "kn", "ko", "kok", "kw", "ky", "lt", "lv", "mi", "mk", "mn", "mr", "ms", "mt", "nb", "nl", "nn", "ns", "pa", "pl", "ps", "pt", "qu", "ro", "ru", "sa", "se", "sk", "sl", "sq", "sr", "sv", "sw", "syr", "ta", "te", "th", "tl", "tn", "tr", "tt", "ts", "uk", "ur", "uz", "vi", "xh", "zh", "zu", } type DefaultLangViewCounter struct { //buckets []*MutexCounterBucket //[OSID]count buckets []int64 //[OSID]count codesToIndices map[string]int insert *sql.Stmt } func NewDefaultLangViewCounter(acc *qgen.Accumulator) (*DefaultLangViewCounter, error) { codesToIndices := make(map[string]int, len(langCodes)) for index, code := range langCodes { codesToIndices[code] = index } co := &DefaultLangViewCounter{ buckets: make([]int64, len(langCodes)), codesToIndices: codesToIndices, insert: acc.Insert("viewchunks_langs").Columns("count,createdAt,lang").Fields("?,UTC_TIMESTAMP(),?").Prepare(), } c.Tasks.FifteenMin.Add(co.Tick) //c.Tasks.Sec.Add(co.Tick) c.Tasks.Shutdown.Add(co.Tick) return co, acc.FirstError() } func (co *DefaultLangViewCounter) Tick() error { for id := 0; id < len(co.buckets); id++ { count := atomic.SwapInt64(&co.buckets[id], 0) e := co.insertChunk(count, id) // TODO: Bulk insert for speed? if e != nil { return errors.Wrap(errors.WithStack(e), "langview counter") } } return nil } func (co *DefaultLangViewCounter) insertChunk(count int64, id int) error { if count == 0 { return nil } langCode := langCodes[id] if langCode == "" { langCode = "none" } c.DebugLogf("Inserting a vchunk with a count of %d for lang %s (%d)", count, langCode, id) _, e := co.insert.Exec(count, langCode) return e } func (co *DefaultLangViewCounter) Bump(langCode string) (validCode bool) { validCode = true id, ok := co.codesToIndices[langCode] if !ok { // TODO: Tell the caller that the code's invalid id = 0 // Unknown validCode = false } // TODO: Test this check c.DebugDetail("buckets ", id, ": ", co.buckets[id]) if len(co.buckets) <= id || id < 0 { return validCode } atomic.AddInt64(&co.buckets[id], 1) return validCode } func (co *DefaultLangViewCounter) Bump2(id int) { // TODO: Test this check c.DebugDetail("bucket ", id, ": ", co.buckets[id]) if len(co.buckets) <= id || id < 0 { return } atomic.AddInt64(&co.buckets[id], 1) } ================================================ FILE: common/counters/memory.go ================================================ package counters import ( "database/sql" "runtime" "sync" "time" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" "github.com/pkg/errors" ) var MemoryCounter *DefaultMemoryCounter type DefaultMemoryCounter struct { insert *sql.Stmt totMem uint64 totCount uint64 stackMem uint64 stackCount uint64 heapMem uint64 heapCount uint64 sync.Mutex } func NewMemoryCounter(acc *qgen.Accumulator) (*DefaultMemoryCounter, error) { co := &DefaultMemoryCounter{ insert: acc.Insert("memchunks").Columns("count,stack,heap,createdAt").Fields("?,?,?,UTC_TIMESTAMP()").Prepare(), } c.Tasks.FifteenMin.Add(co.Tick) //c.Tasks.Sec.Add(co.Tick) c.Tasks.Shutdown.Add(co.Tick) ticker := time.NewTicker(time.Minute) go func() { defer c.EatPanics() for { select { case <-ticker.C: var m runtime.MemStats runtime.ReadMemStats(&m) co.Lock() co.totCount++ co.totMem += m.Sys co.stackCount++ co.stackMem += m.StackInuse co.heapCount++ co.heapMem += m.HeapAlloc co.Unlock() } } }() return co, acc.FirstError() } func (co *DefaultMemoryCounter) Tick() (e error) { var m runtime.MemStats runtime.ReadMemStats(&m) var rTotMem, rTotCount, rStackMem, rStackCount, rHeapMem, rHeapCount uint64 co.Lock() rTotMem = co.totMem rTotCount = co.totCount rStackMem = co.stackMem rStackCount = co.stackCount rHeapMem = co.heapMem rHeapCount = co.heapCount co.totMem = 0 co.totCount = 0 co.stackMem = 0 co.stackCount = 0 co.heapMem = 0 co.heapCount = 0 co.Unlock() var avgMem, avgStack, avgHeap uint64 avgMem = (rTotMem + m.Sys) / (rTotCount + 1) avgStack = (rStackMem + m.StackInuse) / (rStackCount + 1) avgHeap = (rHeapMem + m.HeapAlloc) / (rHeapCount + 1) c.DebugLogf("Inserting a memchunk with a value of %d - %d - %d", avgMem, avgStack, avgHeap) _, e = co.insert.Exec(avgMem, avgStack, avgHeap) if e != nil { return errors.Wrap(errors.WithStack(e), "mem counter") } return nil } ================================================ FILE: common/counters/performance.go ================================================ package counters import ( "database/sql" "math" "time" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" "github.com/pkg/errors" ) var PerfCounter *DefaultPerfCounter type PerfCounterBucket struct { low *MutexCounter64Bucket high *MutexCounter64Bucket avg *MutexCounter64Bucket } // TODO: Track perf on a per route basis type DefaultPerfCounter struct { buckets []*PerfCounterBucket insert *sql.Stmt } func NewDefaultPerfCounter(acc *qgen.Accumulator) (*DefaultPerfCounter, error) { co := &DefaultPerfCounter{ buckets: []*PerfCounterBucket{ { low: &MutexCounter64Bucket{counter: math.MaxInt64}, high: &MutexCounter64Bucket{counter: 0}, avg: &MutexCounter64Bucket{counter: 0}, }, }, insert: acc.Insert("perfchunks").Columns("low,high,avg,createdAt").Fields("?,?,?,UTC_TIMESTAMP()").Prepare(), } c.Tasks.FifteenMin.Add(co.Tick) //c.Tasks.Sec.Add(co.Tick) c.Tasks.Shutdown.Add(co.Tick) return co, acc.FirstError() } func (co *DefaultPerfCounter) Tick() error { getCounter := func(b *MutexCounter64Bucket) (c int64) { b.Lock() c = b.counter b.counter = 0 b.Unlock() return c } var low int64 hTbl := c.GetHookTable() for _, b := range co.buckets { b.low.Lock() low, b.low.counter = b.low.counter, math.MaxInt64 b.low.Unlock() if low == math.MaxInt64 { low = 0 } high := getCounter(b.high) avg := getCounter(b.avg) c.H_counters_perf_tick_row_hook(hTbl, low, high, avg) if e := co.insertChunk(low, high, avg); e != nil { // TODO: Bulk insert for speed? return errors.Wrap(errors.WithStack(e), "perf counter") } } return nil } func (co *DefaultPerfCounter) insertChunk(low, high, avg int64) error { if low == 0 && high == 0 && avg == 0 { return nil } c.DebugLogf("Inserting a pchunk with low %d, high %d, avg %d", low, high, avg) if c.Dev.LogNewLongRoute && high > (5*1000*1000) { c.Logf("pchunk high %d", high) } _, e := co.insert.Exec(low, high, avg) return e } func (co *DefaultPerfCounter) Push(dur time.Duration /*,_ bool*/) { id := 0 b := co.buckets[id] //c.DebugDetail("buckets ", id, ": ", b) micro := dur.Microseconds() if micro >= math.MaxInt32 { c.LogWarning(errors.New("dur should not be int32 max or higher")) } low := b.low low.Lock() if micro < low.counter { low.counter = micro } low.Unlock() high := b.high high.Lock() if micro > high.counter { high.counter = micro } high.Unlock() avg := b.avg avg.Lock() if micro != avg.counter { if avg.counter == 0 { avg.counter = micro } else { avg.counter = (micro + avg.counter) / 2 } } avg.Unlock() } ================================================ FILE: common/counters/posts.go ================================================ package counters import ( "database/sql" "sync/atomic" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" "github.com/pkg/errors" ) var PostCounter *DefaultPostCounter type DefaultPostCounter struct { buckets [2]int64 currentBucket int64 insert *sql.Stmt } func NewPostCounter() (*DefaultPostCounter, error) { acc := qgen.NewAcc() co := &DefaultPostCounter{ currentBucket: 0, insert: acc.Insert("postchunks").Columns("count,createdAt").Fields("?,UTC_TIMESTAMP()").Prepare(), } c.Tasks.FifteenMin.Add(co.Tick) //c.Tasks.Sec.Add(co.Tick) c.Tasks.Shutdown.Add(co.Tick) return co, acc.FirstError() } func (co *DefaultPostCounter) Tick() (err error) { oldBucket := co.currentBucket var nextBucket int64 // 0 if co.currentBucket == 0 { nextBucket = 1 } atomic.AddInt64(&co.buckets[oldBucket], co.buckets[nextBucket]) atomic.StoreInt64(&co.buckets[nextBucket], 0) atomic.StoreInt64(&co.currentBucket, nextBucket) previousViewChunk := co.buckets[oldBucket] atomic.AddInt64(&co.buckets[oldBucket], -previousViewChunk) err = co.insertChunk(previousViewChunk) if err != nil { return errors.Wrap(errors.WithStack(err), "post counter") } return nil } func (co *DefaultPostCounter) Bump() { atomic.AddInt64(&co.buckets[co.currentBucket], 1) } func (co *DefaultPostCounter) insertChunk(count int64) error { if count == 0 { return nil } c.DebugLogf("Inserting a postchunk with a count of %d", count) _, err := co.insert.Exec(count) return err } ================================================ FILE: common/counters/referrers.go ================================================ package counters import ( "database/sql" "sync" "sync/atomic" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" "github.com/pkg/errors" ) var ReferrerTracker *DefaultReferrerTracker // Add ReferrerItems here after they've had zero views for a while var referrersToDelete = make(map[string]*ReferrerItem) type ReferrerItem struct { Count int64 } // ? We'll track referrer domains here rather than the exact URL they arrived from for now, we'll think about expanding later // ? Referrers are fluid and ever-changing so we have to use string keys rather than 'enum' ints type DefaultReferrerTracker struct { odd map[string]*ReferrerItem even map[string]*ReferrerItem oddLock sync.RWMutex evenLock sync.RWMutex insert *sql.Stmt } func NewDefaultReferrerTracker() (*DefaultReferrerTracker, error) { acc := qgen.NewAcc() refTracker := &DefaultReferrerTracker{ odd: make(map[string]*ReferrerItem), even: make(map[string]*ReferrerItem), insert: acc.Insert("viewchunks_referrers").Columns("count,createdAt,domain").Fields("?,UTC_TIMESTAMP(),?").Prepare(), // TODO: Do something more efficient than doing a query for each referrer } c.Tasks.FifteenMin.Add(refTracker.Tick) //c.Tasks.Sec.Add(refTracker.Tick) c.Tasks.Shutdown.Add(refTracker.Tick) return refTracker, acc.FirstError() } // TODO: Move this and the other view tickers out of the main task loop to avoid blocking other tasks? func (ref *DefaultReferrerTracker) Tick() (err error) { for referrer, counter := range referrersToDelete { // Handle views which squeezed through the gaps at the last moment count := counter.Count if count != 0 { err := ref.insertChunk(referrer, count) // TODO: Bulk insert for speed? if err != nil { return errors.Wrap(errors.WithStack(err), "ref counter") } } delete(referrersToDelete, referrer) } // Run the queries and schedule zero view refs for deletion from memory refLoop := func(l *sync.RWMutex, m map[string]*ReferrerItem) error { l.Lock() defer l.Unlock() for referrer, counter := range m { if counter.Count == 0 { referrersToDelete[referrer] = counter delete(m, referrer) } count := atomic.SwapInt64(&counter.Count, 0) err := ref.insertChunk(referrer, count) // TODO: Bulk insert for speed? if err != nil { return errors.Wrap(errors.WithStack(err), "ref counter") } } return nil } err = refLoop(&ref.oddLock, ref.odd) if err != nil { return err } return refLoop(&ref.evenLock, ref.even) } func (ref *DefaultReferrerTracker) insertChunk(referrer string, count int64) error { if count == 0 { return nil } c.DebugDetailf("Inserting a vchunk with a count of %d for ref %s", count, referrer) _, err := ref.insert.Exec(count, referrer) return err } func (ref *DefaultReferrerTracker) Bump(referrer string) { if referrer == "" { return } var refItem *ReferrerItem // Slightly crude and rudimentary, but it should give a basic degree of sharding if referrer[0]%2 == 0 { ref.evenLock.RLock() refItem = ref.even[referrer] ref.evenLock.RUnlock() if refItem != nil { atomic.AddInt64(&refItem.Count, 1) } else { ref.evenLock.Lock() ref.even[referrer] = &ReferrerItem{Count: 1} ref.evenLock.Unlock() } } else { ref.oddLock.RLock() refItem = ref.odd[referrer] ref.oddLock.RUnlock() if refItem != nil { atomic.AddInt64(&refItem.Count, 1) } else { ref.oddLock.Lock() ref.odd[referrer] = &ReferrerItem{Count: 1} ref.oddLock.Unlock() } } } ================================================ FILE: common/counters/requests.go ================================================ package counters import ( "database/sql" "sync/atomic" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" "github.com/pkg/errors" ) // TODO: Rename this? var GlobalViewCounter *DefaultViewCounter // TODO: Rename this and shard it? type DefaultViewCounter struct { buckets [2]int64 currentBucket int64 insert *sql.Stmt } func NewGlobalViewCounter(acc *qgen.Accumulator) (*DefaultViewCounter, error) { co := &DefaultViewCounter{ currentBucket: 0, insert: acc.Insert("viewchunks").Columns("count,createdAt,route").Fields("?,UTC_TIMESTAMP(),''").Prepare(), } c.Tasks.FifteenMin.Add(co.Tick) // This is run once every fifteen minutes to match the frequency of the RouteViewCounter //c.Tasks.Sec.Add(co.Tick) c.Tasks.Shutdown.Add(co.Tick) return co, acc.FirstError() } // TODO: Simplify the atomics used here func (co *DefaultViewCounter) Tick() (err error) { oldBucket := co.currentBucket var nextBucket int64 // 0 if co.currentBucket == 0 { nextBucket = 1 } atomic.AddInt64(&co.buckets[oldBucket], co.buckets[nextBucket]) atomic.StoreInt64(&co.buckets[nextBucket], 0) atomic.StoreInt64(&co.currentBucket, nextBucket) previousViewChunk := co.buckets[oldBucket] atomic.AddInt64(&co.buckets[oldBucket], -previousViewChunk) err = co.insertChunk(previousViewChunk) if err != nil { return errors.Wrap(errors.WithStack(err), "req counter") } return nil } func (co *DefaultViewCounter) Bump() { atomic.AddInt64(&co.buckets[co.currentBucket], 1) } func (co *DefaultViewCounter) insertChunk(count int64) error { if count == 0 { return nil } c.DebugLogf("Inserting a vchunk with a count of %d", count) _, err := co.insert.Exec(count) return err } ================================================ FILE: common/counters/routes.go ================================================ package counters import ( "database/sql" "sync" "sync/atomic" "time" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" "github.com/Azareal/Gosora/uutils" "github.com/pkg/errors" ) var RouteViewCounter *DefaultRouteViewCounter type RVBucket struct { counter int64 avg int sync.Mutex } // TODO: Make this lockless? type DefaultRouteViewCounter struct { buckets []*RVBucket //[RouteID]count insert *sql.Stmt insert5 *sql.Stmt } func NewDefaultRouteViewCounter(acc *qgen.Accumulator) (*DefaultRouteViewCounter, error) { routeBuckets := make([]*RVBucket, len(routeMapEnum)) for bucketID, _ := range routeBuckets { routeBuckets[bucketID] = &RVBucket{counter: 0, avg: 0} } fields := "?,?,UTC_TIMESTAMP(),?" co := &DefaultRouteViewCounter{ buckets: routeBuckets, insert: acc.Insert("viewchunks").Columns("count,avg,createdAt,route").Fields(fields).Prepare(), insert5: acc.BulkInsert("viewchunks").Columns("count,avg,createdAt,route").Fields(fields, fields, fields, fields, fields).Prepare(), } if !c.Config.DisableAnalytics { c.Tasks.FifteenMin.Add(co.Tick) // There could be a lot of routes, so we don't want to be running this every second //c.Tasks.Sec.Add(co.Tick) c.Tasks.Shutdown.Add(co.Tick) } return co, acc.FirstError() } type RVCount struct { RouteID int Count int64 Avg int } func (co *DefaultRouteViewCounter) Tick() (err error) { var tb []RVCount for routeID, b := range co.buckets { var avg int count := atomic.SwapInt64(&b.counter, 0) b.Lock() avg = b.avg b.avg = 0 b.Unlock() if count == 0 { continue } tb = append(tb, RVCount{routeID, count, avg}) } // TODO: Expand on this? var i int if len(tb) >= 5 { for ; len(tb) > (i + 5); i += 5 { err := co.insert5Chunk(tb[i : i+5]) if err != nil { c.DebugLogf("tb: %+v\n", tb) c.DebugLog("i: ", i) return errors.Wrap(errors.WithStack(err), "route counter x 5") } } } for ; len(tb) > i; i++ { it := tb[i] err = co.insertChunk(it.Count, it.Avg, it.RouteID) if err != nil { c.DebugLogf("tb: %+v\n", tb) c.DebugLog("i: ", i) return errors.Wrap(errors.WithStack(err), "route counter") } } return nil } func (co *DefaultRouteViewCounter) insertChunk(count int64, avg, route int) error { routeName := reverseRouteMapEnum[route] c.DebugLogf("Inserting vchunk with count %d, avg %d for route %s (%d)", count, avg, routeName, route) _, err := co.insert.Exec(count, avg, routeName) return err } func (co *DefaultRouteViewCounter) insert5Chunk(rvs []RVCount) error { args := make([]interface{}, len(rvs)*3) i := 0 for _, rv := range rvs { routeName := reverseRouteMapEnum[rv.RouteID] if rv.Avg == 0 { c.DebugLogf("Queueing vchunk with count %d for routes %s (%d)", rv.Count, routeName, rv.RouteID) } else { c.DebugLogf("Queueing vchunk with count %d, avg %d for routes %s (%d)", rv.Count, rv.Avg, routeName, rv.RouteID) } args[i] = rv.Count args[i+1] = rv.Avg args[i+2] = routeName i += 3 } c.DebugDetailf("args: %+v\n", args) _, err := co.insert5.Exec(args...) return err } func (co *DefaultRouteViewCounter) Bump(route int) { if c.Config.DisableAnalytics { return } // TODO: Test this check b := co.buckets[route] c.DebugDetail("bucket ", route, ": ", b) if len(co.buckets) <= route || route < 0 { return } atomic.AddInt64(&b.counter, 1) } // TODO: Eliminate the lock? func (co *DefaultRouteViewCounter) Bump2(route int, t time.Time) { if c.Config.DisableAnalytics { return } // TODO: Test this check b := co.buckets[route] c.DebugDetail("bucket ", route, ": ", b) if len(co.buckets) <= route || route < 0 { return } micro := int(time.Since(t).Microseconds()) //co.PerfCounter.Push(since, true) atomic.AddInt64(&b.counter, 1) b.Lock() if micro != b.avg { if b.avg == 0 { b.avg = micro } else { b.avg = (micro + b.avg) / 2 } } b.Unlock() } // TODO: Eliminate the lock? func (co *DefaultRouteViewCounter) Bump3(route int, nano int64) { if c.Config.DisableAnalytics { return } // TODO: Test this check b := co.buckets[route] c.DebugDetail("bucket ", route, ": ", b) if len(co.buckets) <= route || route < 0 { return } micro := int((uutils.Nanotime() - nano) / 1000) //co.PerfCounter.Push(since, true) atomic.AddInt64(&b.counter, 1) b.Lock() if micro != b.avg { if b.avg == 0 { b.avg = micro } else { b.avg = (micro + b.avg) / 2 } } b.Unlock() } ================================================ FILE: common/counters/systems.go ================================================ package counters import ( "database/sql" "sync/atomic" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" "github.com/pkg/errors" ) var OSViewCounter *DefaultOSViewCounter type DefaultOSViewCounter struct { buckets []int64 //[OSID]count insert *sql.Stmt } func NewDefaultOSViewCounter(acc *qgen.Accumulator) (*DefaultOSViewCounter, error) { co := &DefaultOSViewCounter{ buckets: make([]int64, len(osMapEnum)), insert: acc.Insert("viewchunks_systems").Columns("count,createdAt,system").Fields("?,UTC_TIMESTAMP(),?").Prepare(), } c.Tasks.FifteenMin.Add(co.Tick) //c.Tasks.Sec.Add(co.Tick) c.Tasks.Shutdown.Add(co.Tick) return co, acc.FirstError() } func (co *DefaultOSViewCounter) Tick() error { for id, _ := range co.buckets { count := atomic.SwapInt64(&co.buckets[id], 0) if e := co.insertChunk(count, id); e != nil { // TODO: Bulk insert for speed? return errors.Wrap(errors.WithStack(e), "system counter") } } return nil } func (co *DefaultOSViewCounter) insertChunk(count int64, os int) error { if count == 0 { return nil } osName := reverseOSMapEnum[os] c.DebugLogf("Inserting a vchunk with a count of %d for OS %s (%d)", count, osName, os) _, err := co.insert.Exec(count, osName) return err } func (co *DefaultOSViewCounter) Bump(id int) { // TODO: Test this check c.DebugDetail("bucket ", id, ": ", co.buckets[id]) if len(co.buckets) <= id || id < 0 { return } atomic.AddInt64(&co.buckets[id], 1) } ================================================ FILE: common/counters/topics.go ================================================ package counters import ( "database/sql" "sync/atomic" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" "github.com/pkg/errors" ) var TopicCounter *DefaultTopicCounter type DefaultTopicCounter struct { buckets [2]int64 currentBucket int64 insert *sql.Stmt } func NewTopicCounter() (*DefaultTopicCounter, error) { acc := qgen.NewAcc() co := &DefaultTopicCounter{ currentBucket: 0, insert: acc.Insert("topicchunks").Columns("count,createdAt").Fields("?,UTC_TIMESTAMP()").Prepare(), } c.Tasks.FifteenMin.Add(co.Tick) //c.Tasks.Sec.Add(co.Tick) c.Tasks.Shutdown.Add(co.Tick) return co, acc.FirstError() } func (co *DefaultTopicCounter) Tick() (e error) { oldBucket := co.currentBucket var nextBucket int64 // 0 if co.currentBucket == 0 { nextBucket = 1 } atomic.AddInt64(&co.buckets[oldBucket], co.buckets[nextBucket]) atomic.StoreInt64(&co.buckets[nextBucket], 0) atomic.StoreInt64(&co.currentBucket, nextBucket) previousViewChunk := co.buckets[oldBucket] atomic.AddInt64(&co.buckets[oldBucket], -previousViewChunk) e = co.insertChunk(previousViewChunk) if e != nil { return errors.Wrap(errors.WithStack(e), "topics counter") } return nil } func (co *DefaultTopicCounter) Bump() { atomic.AddInt64(&co.buckets[co.currentBucket], 1) } func (co *DefaultTopicCounter) insertChunk(count int64) error { if count == 0 { return nil } c.DebugLogf("Inserting a topicchunk with a count of %d", count) _, e := co.insert.Exec(count) return e } ================================================ FILE: common/counters/topics_views.go ================================================ package counters import ( "database/sql" "strconv" "strings" "sync" "sync/atomic" "time" c "github.com/Azareal/Gosora/common" qgen "github.com/Azareal/Gosora/query_gen" "github.com/pkg/errors" ) var TopicViewCounter *DefaultTopicViewCounter // TODO: Use two odd-even maps for now, and move to something more concurrent later, maybe a sharded map? type DefaultTopicViewCounter struct { oddTopics map[int]*RWMutexCounterBucket // map[tid]struct{counter,sync.RWMutex} evenTopics map[int]*RWMutexCounterBucket oddLock sync.RWMutex evenLock sync.RWMutex weekState byte update *sql.Stmt resetOdd *sql.Stmt resetEven *sql.Stmt resetBoth *sql.Stmt insertListBuf []TopicViewInsert saveTick *SavedTick } func NewDefaultTopicViewCounter() (*DefaultTopicViewCounter, error) { acc := qgen.NewAcc() t := "topics" co := &DefaultTopicViewCounter{ oddTopics: make(map[int]*RWMutexCounterBucket), evenTopics: make(map[int]*RWMutexCounterBucket), //update: acc.Update(t).Set("views=views+?").Where("tid=?").Prepare(), update: acc.Update(t).Set("views=views+?,weekEvenViews=weekEvenViews+?,weekOddViews=weekOddViews+?").Where("tid=?").Prepare(), resetOdd: acc.Update(t).Set("weekOddViews=0").Prepare(), resetEven: acc.Update(t).Set("weekEvenViews=0").Prepare(), resetBoth: acc.Update(t).Set("weekOddViews=0,weekEvenViews=0").Prepare(), //insertListBuf: make([]TopicViewInsert, 1024), } e := co.WeekResetInit() if e != nil { return co, e } tick := func(f func() error) { c.Tasks.FifteenMin.Add(f) // Who knows how many topics we have queued up, we probably don't want this running too frequently //c.Tasks.Sec.Add(f) c.Tasks.Shutdown.Add(f) } tick(co.Tick) tick(co.WeekResetTick) return co, acc.FirstError() } type TopicViewInsert struct { Count int TopicID int } type SavedTick struct { I int I2 int } func (co *DefaultTopicViewCounter) handleInsertListBuf(i, i2 int) error { ilb := co.insertListBuf var lastSuccess int for i3 := i2; i3 < i; i3++ { iitem := ilb[i3] if e := co.insertChunk(iitem.Count, iitem.TopicID); e != nil { co.saveTick = &SavedTick{I: i, I2: lastSuccess + 1} for i3 := i2; i3 < i && i3 <= lastSuccess; i3++ { ilb[i3].Count, ilb[i3].TopicID = 0, 0 } return errors.Wrap(errors.WithStack(e), "topicview counter") } lastSuccess = i3 } for i3 := i2; i3 < i; i3++ { ilb[i3].Count, ilb[i3].TopicID = 0, 0 } return nil } func (co *DefaultTopicViewCounter) Tick() error { // TODO: Fold multiple 1 view topics into one query /*if co.saveTick != nil { e := co.handleInsertListBuf(co.saveTick.I, co.saveTick.I2) if e != nil { return e } co.saveTick = nil }*/ cLoop := func(l *sync.RWMutex, m map[int]*RWMutexCounterBucket) error { //i := 0 l.RLock() for topicID, topic := range m { l.RUnlock() var count int topic.RLock() count = topic.counter topic.RUnlock() // TODO: Only delete the bucket when it's zero to avoid hitting popular topics? l.Lock() delete(m, topicID) l.Unlock() /*if len(co.insertListBuf) >= i { co.insertListBuf[i].Count = count co.insertListBuf[i].TopicID = topicID i++ } else if i < 4096 { co.insertListBuf = append(co.insertListBuf, TopicViewInsert{count, topicID}) } else */if e := co.insertChunk(count, topicID); e != nil { return errors.Wrap(errors.WithStack(e), "topicview counter") } l.RLock() } l.RUnlock() return nil //co.handleInsertListBuf(i, 0) } e := cLoop(&co.oddLock, co.oddTopics) if e != nil { return e } return cLoop(&co.evenLock, co.evenTopics) } func (co *DefaultTopicViewCounter) WeekResetInit() error { lastWeekResetStr, e := c.Meta.Get("lastWeekReset") if e != nil && e != sql.ErrNoRows { return e } spl := strings.Split(lastWeekResetStr, "-") if len(spl) <= 1 { return nil } weekState, e := strconv.Atoi(spl[1]) if e != nil { return e } co.weekState = byte(weekState) unixLastWeekReset, e := strconv.ParseInt(spl[0], 10, 64) if e != nil { return e } resetTime := time.Unix(unixLastWeekReset, 0) if time.Since(resetTime).Hours() >= (24 * 7) { _, e = co.resetBoth.Exec() } return e } func (co *DefaultTopicViewCounter) WeekResetTick() (e error) { now := time.Now() _, week := now.ISOWeek() if week != int(co.weekState) { if week%2 == 0 { // is even? _, e = co.resetOdd.Exec() } else { _, e = co.resetEven.Exec() } co.weekState = byte(week) } // TODO: Retry? if e != nil { return e } return c.Meta.Set("lastWeekReset", strconv.FormatInt(now.Unix(), 10)+"-"+strconv.Itoa(week)) } // TODO: Optimise this further. E.g. Using IN() on every one view topic. Rinse and repeat for two views, three views, four views and five views. func (co *DefaultTopicViewCounter) insertChunk(count, topicID int) (err error) { if count == 0 { return nil } c.DebugLogf("Inserting %d views into topic %d", count, topicID) even, odd := 0, 0 _, week := time.Now().ISOWeek() if week%2 == 0 { // is even? even += count } else { odd += count } if true { _, err = co.update.Exec(count, even, odd, topicID) } else { _, err = co.update.Exec(count, topicID) } if err == sql.ErrNoRows { return nil } else if err != nil { return err } // TODO: Add a way to disable this for extra speed ;) tc := c.Topics.GetCache() if tc != nil { t, err := tc.Get(topicID) if err == sql.ErrNoRows { return nil } else if err != nil { return err } atomic.AddInt64(&t.ViewCount, int64(count)) } return nil } func (co *DefaultTopicViewCounter) Bump(topicID int) { // Is the ID even? if topicID%2 == 0 { co.evenLock.RLock() t, ok := co.evenTopics[topicID] co.evenLock.RUnlock() if ok { t.Lock() t.counter++ t.Unlock() } else { co.evenLock.Lock() co.evenTopics[topicID] = &RWMutexCounterBucket{counter: 1} co.evenLock.Unlock() } return } co.oddLock.RLock() t, ok := co.oddTopics[topicID] co.oddLock.RUnlock() if ok { t.Lock() t.counter++ t.Unlock() } else { co.oddLock.Lock() co.oddTopics[topicID] = &RWMutexCounterBucket{counter: 1} co.oddLock.Unlock() } } ================================================ FILE: common/disk.go ================================================ package common import ( "path/filepath" "os" ) func DirSize(path string) (int, error) { var size int64 err := filepath.Walk(path, func(_ string, file os.FileInfo, err error) error { if err != nil { return err } if !file.IsDir() { size += file.Size() } return err }) return int(size), err } ================================================ FILE: common/email.go ================================================ package common import ( "crypto/tls" "fmt" "net/mail" "net/smtp" "strings" p "github.com/Azareal/Gosora/common/phrases" ) func SendActivationEmail(username, email, token string) error { schema := "http" if Config.SslSchema { schema += "s" } // TODO: Move these to the phrase system subject := "Account Activation - " + Site.Name msg := "Dear " + username + ", to complete your registration on our forums, we need you to validate your email, so that we can confirm that this email actually belongs to you.\n\nClick on the following link to do so. " + schema + "://" + Site.URL + "/user/edit/token/" + token + "\n\nIf you haven't created an account here, then please feel free to ignore this email.\nWe're sorry for the inconvenience this may have caused." return SendEmail(email, subject, msg) } func SendValidationEmail(username, email, token string) error { schema := "http" if Config.SslSchema { schema += "s" } r := func(body *string) func(name, val string) { return func(name, val string) { *body = strings.Replace(*body, "{{"+name+"}}", val, -1) } } subject := p.GetAccountPhrase("ValidateEmailSubject") r1 := r(&subject) r1("name", Site.Name) body := p.GetAccountPhrase("ValidateEmailBody") r2 := r(&body) r2("username", username) r2("schema", schema) r2("url", Site.URL) r2("token", token) return SendEmail(email, subject, body) } // TODO: Refactor this func SendEmail(email, subject, msg string) (err error) { // This hook is useful for plugin_sendmail or for testing tools. Possibly to hook it into some sort of mail server? ret, hasHook := GetHookTable().VhookNeedHook("email_send_intercept", email, subject, msg) if hasHook { return ret.(error) } from := mail.Address{"", Site.Email} to := mail.Address{"", email} headers := make(map[string]string) headers["From"] = from.String() headers["To"] = to.String() headers["Subject"] = subject body := "" for k, v := range headers { body += fmt.Sprintf("%s: %s\r\n", k, v) } body += "\r\n" + msg var c *smtp.Client var conn *tls.Conn if Config.SMTPEnableTLS { tlsconfig := &tls.Config{ InsecureSkipVerify: true, ServerName: Config.SMTPServer, } conn, err = tls.Dial("tcp", Config.SMTPServer+":"+Config.SMTPPort, tlsconfig) if err != nil { LogWarning(err) return err } c, err = smtp.NewClient(conn, Config.SMTPServer) } else { c, err = smtp.Dial(Config.SMTPServer + ":" + Config.SMTPPort) } if err != nil { LogWarning(err) return err } if Config.SMTPUsername != "" { auth := smtp.PlainAuth("", Config.SMTPUsername, Config.SMTPPassword, Config.SMTPServer) err = c.Auth(auth) if err != nil { LogWarning(err) return err } } if err = c.Mail(from.Address); err != nil { LogWarning(err) return err } if err = c.Rcpt(to.Address); err != nil { LogWarning(err) return err } w, err := c.Data() if err != nil { LogWarning(err) return err } _, err = w.Write([]byte(body)) if err != nil { LogWarning(err) return err } if err = w.Close(); err != nil { LogWarning(err) return err } if err = c.Quit(); err != nil { LogWarning(err) return err } return nil } ================================================ FILE: common/email_store.go ================================================ package common import ( "database/sql" qgen "github.com/Azareal/Gosora/query_gen" ) var Emails EmailStore type Email struct { UserID int Email string Validated bool Primary bool Token string } type EmailStore interface { // TODO: Add an autoincrement key Get(u *User, email string) (Email, error) GetEmailsByUser(u *User) (emails []Email, err error) Add(uid int, email, token string) error Delete(uid int, email string) error VerifyEmail(email string) error } type DefaultEmailStore struct { get *sql.Stmt getEmailsByUser *sql.Stmt add *sql.Stmt delete *sql.Stmt verifyEmail *sql.Stmt } func NewDefaultEmailStore(acc *qgen.Accumulator) (*DefaultEmailStore, error) { e := "emails" return &DefaultEmailStore{ get: acc.Select(e).Columns("email,validated,token").Where("uid=? AND email=?").Prepare(), getEmailsByUser: acc.Select(e).Columns("email,validated,token").Where("uid=?").Prepare(), add: acc.Insert(e).Columns("uid,email,validated,token").Fields("?,?,?,?").Prepare(), delete: acc.Delete(e).Where("uid=? AND email=?").Prepare(), // Need to fix this: Empty string isn't working, it gets set to 1 instead x.x -- Has this been fixed? verifyEmail: acc.Update(e).Set("validated=1,token=''").Where("email=?").Prepare(), }, acc.FirstError() } func (s *DefaultEmailStore) Get(user *User, email string) (Email, error) { e := Email{UserID: user.ID, Primary: email != "" && user.Email == email} err := s.get.QueryRow(user.ID, email).Scan(&e.Email, &e.Validated, &e.Token) return e, err } func (s *DefaultEmailStore) GetEmailsByUser(user *User) (emails []Email, err error) { e := Email{UserID: user.ID} rows, err := s.getEmailsByUser.Query(user.ID) if err != nil { return emails, err } defer rows.Close() for rows.Next() { err := rows.Scan(&e.Email, &e.Validated, &e.Token) if err != nil { return emails, err } if e.Email == user.Email { e.Primary = true } emails = append(emails, e) } return emails, rows.Err() } func (s *DefaultEmailStore) Add(uid int, email, token string) error { email = CanonEmail(SanitiseSingleLine(email)) _, err := s.add.Exec(uid, email, 0, token) return err } func (s *DefaultEmailStore) Delete(uid int, email string) error { _, err := s.delete.Exec(uid, email) return err } func (s *DefaultEmailStore) VerifyEmail(email string) error { email = CanonEmail(SanitiseSingleLine(email)) _, err := s.verifyEmail.Exec(email) return err } ================================================ FILE: common/errors.go ================================================ package common import ( "fmt" "log" "net/http" "runtime/debug" "strings" "sync" "sync/atomic" p "github.com/Azareal/Gosora/common/phrases" ) type ErrorItem struct { error Stack []byte } // ! The errorBuffer uses o(n) memory, we should probably do something about that // TODO: Use the errorBuffer variable to construct the system log in the Control Panel. Should we log errors caused by users too? Or just collect statistics on those or do nothing? Intercept recover()? Could we intercept the logger instead here? We might get too much information, if we intercept the logger, maybe make it part of the Debug page? // ? - Should we pass Header / HeaderLite rather than forcing the errors to pull the global Header instance? var errorBufferMutex sync.RWMutex //var errorBuffer []ErrorItem var ErrorCountSinceStartup int64 //var notfoundCountPerSecond int //var nopermsCountPerSecond int // A blank list to fill out that parameter in Page for routes which don't use it var tList []interface{} // WIP, a new system to propagate errors up from routes type RouteError interface { Type() string Error() string Cause() string JSON() bool Handled() bool Wrap(string) } type RouteErrorImpl struct { userText string sysText string system bool json bool handled bool } func (err *RouteErrorImpl) Type() string { // System errors may contain sensitive information we don't want the user to see if err.system { return "system" } return "user" } func (err *RouteErrorImpl) Error() string { return err.userText } func (err *RouteErrorImpl) Cause() string { if err.sysText == "" { return err.Error() } return err.sysText } // Respond with JSON? func (err *RouteErrorImpl) JSON() bool { return err.json } // Has this error been dealt with elsewhere? func (err *RouteErrorImpl) Handled() bool { return err.handled } // Move the current error into the system error slot and add a new one to the user error slot to show the user func (err *RouteErrorImpl) Wrap(userErr string) { err.sysText = err.userText err.userText = userErr } func HandledRouteError() RouteError { return &RouteErrorImpl{"", "", false, false, true} } func Error(errmsg string) RouteError { return &RouteErrorImpl{errmsg, "", false, false, false} } func FromError(err error) RouteError { return &RouteErrorImpl{err.Error(), "", false, false, false} } func ErrorJSQ(errmsg string, js bool) RouteError { return &RouteErrorImpl{errmsg, "", false, js, false} } func SysError(errmsg string) RouteError { return &RouteErrorImpl{errmsg, errmsg, true, false, false} } // LogError logs internal handler errors which can't be handled with InternalError() as a wrapper for log.Fatal(), we might do more with it in the future. // TODO: Clean-up extra as a way of passing additional context func LogError(err error, extra ...string) { LogWarning(err, extra...) ErrLogger.Fatal("") } func LogWarning(err error, extra ...string) { var esb strings.Builder for _, extraBit := range extra { esb.WriteString(extraBit) esb.WriteRune(10) } if err == nil { esb.WriteString("nil error found") } else { esb.WriteString(err.Error()) } esb.WriteRune(10) errmsg := esb.String() errorBufferMutex.Lock() defer errorBufferMutex.Unlock() stack := debug.Stack() // debug.Stack() can't be executed concurrently, so we'll guard this with a mutex too Err(errmsg, string(stack)) //errorBuffer = append(errorBuffer, ErrorItem{err, stack}) atomic.AddInt64(&ErrorCountSinceStartup,1) } func errorHeader(w http.ResponseWriter, u *User, title string) *Header { h := DefaultHeader(w, u) h.Title = title h.Zone = "error" return h } // TODO: Dump the request? // InternalError is the main function for handling internal errors, while simultaneously printing out a page for the end-user to let them know that *something* has gone wrong // ? - Add a user parameter? // ! Do not call CustomError here or we might get an error loop func InternalError(err error, w http.ResponseWriter, r *http.Request) RouteError { pi := ErrorPage{errorHeader(w, &GuestUser, p.GetErrorPhrase("internal_error_title")), p.GetErrorPhrase("internal_error_body")} handleErrorTemplate(w, r, pi, 500) LogError(err) return HandledRouteError() } // InternalErrorJSQ is the JSON "maybe" version of InternalError which can handle both JSON and normal requests // ? - Add a user parameter? func InternalErrorJSQ(err error, w http.ResponseWriter, r *http.Request, js bool) RouteError { if !js { return InternalError(err, w, r) } return InternalErrorJS(err, w, r) } // InternalErrorJS is the JSON version of InternalError on routes we know will only be requested via JSON. E.g. An API. // ? - Add a user parameter? func InternalErrorJS(err error, w http.ResponseWriter, r *http.Request) RouteError { w.WriteHeader(500) writeJsonError(p.GetErrorPhrase("internal_error_body"), w) LogError(err) return HandledRouteError() } // When the task system detects if the database is down, some database errors might slip by this func DatabaseError(w http.ResponseWriter, r *http.Request) RouteError { pi := ErrorPage{errorHeader(w, &GuestUser, p.GetErrorPhrase("internal_error_title")), p.GetErrorPhrase("internal_error_body")} handleErrorTemplate(w, r, pi, 500) return HandledRouteError() } func InternalErrorXML(err error, w http.ResponseWriter, r *http.Request) RouteError { w.Header().Set("Content-Type", "application/xml") w.WriteHeader(500) w.Write([]byte(` ` + p.GetErrorPhrase("internal_error_body") + ``)) LogError(err) return HandledRouteError() } // TODO: Stop killing the instance upon hitting an error with InternalError* and deprecate this func SilentInternalErrorXML(err error, w http.ResponseWriter, r *http.Request) RouteError { w.Header().Set("Content-Type", "application/xml") w.WriteHeader(500) w.Write([]byte(` ` + p.GetErrorPhrase("internal_error_body") + ``)) log.Print("InternalError: ", err) return HandledRouteError() } // ! Do not call CustomError here otherwise we might get an error loop func PreError(errmsg string, w http.ResponseWriter, r *http.Request) RouteError { pi := ErrorPage{errorHeader(w, &GuestUser, p.GetErrorPhrase("error_title")), errmsg} handleErrorTemplate(w, r, pi, 500) return HandledRouteError() } func PreErrorJS(errmsg string, w http.ResponseWriter, r *http.Request) RouteError { w.WriteHeader(500) writeJsonError(errmsg, w) return HandledRouteError() } func PreErrorJSQ(errmsg string, w http.ResponseWriter, r *http.Request, js bool) RouteError { if !js { return PreError(errmsg, w, r) } return PreErrorJS(errmsg, w, r) } // LocalError is an error shown to the end-user when something goes wrong and it's not the software's fault // TODO: Pass header in for this and similar errors instead of having to pass in both user and w? Would also allow for more stateful things, although this could be a problem /*func LocalError(errmsg string, w http.ResponseWriter, r *http.Request, user *User) RouteError { w.WriteHeader(500) pi := ErrorPage{errorHeader(w, user, p.GetErrorPhrase("local_error_title")), errmsg} handleErrorTemplate(w, r, pi) return HandledRouteError() }*/ func LocalError(errmsg string, w http.ResponseWriter, r *http.Request, u *User) RouteError { return SimpleError(errmsg, w, r, errorHeader(w, u, "")) } func LocalErrorf(errmsg string, w http.ResponseWriter, r *http.Request, u *User, params ...interface{}) RouteError { return LocalError(fmt.Sprintf(errmsg, params), w, r, u) } func SimpleError(errmsg string, w http.ResponseWriter, r *http.Request, h *Header) RouteError { if h == nil { h = errorHeader(w, &GuestUser, p.GetErrorPhrase("local_error_title")) } else { h.Title = p.GetErrorPhrase("local_error_title") } pi := ErrorPage{h, errmsg} handleErrorTemplate(w, r, pi, 500) return HandledRouteError() } func LocalErrorJSQ(errmsg string, w http.ResponseWriter, r *http.Request, u *User, js bool) RouteError { if !js { return SimpleError(errmsg, w, r, errorHeader(w, u, "")) } return LocalErrorJS(errmsg, w, r) } func LocalErrorJS(errmsg string, w http.ResponseWriter, r *http.Request) RouteError { w.WriteHeader(500) writeJsonError(errmsg, w) return HandledRouteError() } // TODO: We might want to centralise the error logic in the future and just return what the error handler needs to construct the response rather than handling it here // NoPermissions is an error shown to the end-user when they try to access an area which they aren't authorised to access func NoPermissions(w http.ResponseWriter, r *http.Request, u *User) RouteError { pi := ErrorPage{errorHeader(w, u, p.GetErrorPhrase("no_permissions_title")), p.GetErrorPhrase("no_permissions_body")} handleErrorTemplate(w, r, pi, 403) return HandledRouteError() } func NoPermissionsJSQ(w http.ResponseWriter, r *http.Request, u *User, js bool) RouteError { if !js { return NoPermissions(w, r, u) } return NoPermissionsJS(w, r, u) } func NoPermissionsJS(w http.ResponseWriter, r *http.Request, u *User) RouteError { w.WriteHeader(403) writeJsonError(p.GetErrorPhrase("no_permissions_body"), w) return HandledRouteError() } // ? - Is this actually used? Should it be used? A ban in Gosora should be more of a permission revocation to stop them posting rather than something which spits up an error page, right? func Banned(w http.ResponseWriter, r *http.Request, u *User) RouteError { pi := ErrorPage{errorHeader(w, u, p.GetErrorPhrase("banned_title")), p.GetErrorPhrase("banned_body")} handleErrorTemplate(w, r, pi, 403) return HandledRouteError() } // nolint // BannedJSQ is the version of the banned error page which handles both JavaScript requests and normal page loads func BannedJSQ(w http.ResponseWriter, r *http.Request, user *User, js bool) RouteError { if !js { return Banned(w, r, user) } return BannedJS(w, r, user) } func BannedJS(w http.ResponseWriter, r *http.Request, u *User) RouteError { w.WriteHeader(403) writeJsonError(p.GetErrorPhrase("banned_body"), w) return HandledRouteError() } // nolint func LoginRequiredJSQ(w http.ResponseWriter, r *http.Request, u *User, js bool) RouteError { if !js { return LoginRequired(w, r, u) } return LoginRequiredJS(w, r, u) } // ? - Where is this used? Should we use it more? // LoginRequired is an error shown to the end-user when they try to access an area which requires them to login func LoginRequired(w http.ResponseWriter, r *http.Request, u *User) RouteError { return CustomError(p.GetErrorPhrase("login_required_body"), 401, p.GetErrorPhrase("no_permissions_title"), w, r, nil, u) } // nolint func LoginRequiredJS(w http.ResponseWriter, r *http.Request, u *User) RouteError { w.WriteHeader(401) writeJsonError(p.GetErrorPhrase("login_required_body"), w) return HandledRouteError() } // SecurityError is used whenever a session mismatch is found // ? - Should we add JS and JSQ versions of this? func SecurityError(w http.ResponseWriter, r *http.Request, u *User) RouteError { pi := ErrorPage{errorHeader(w, u, p.GetErrorPhrase("security_error_title")), p.GetErrorPhrase("security_error_body")} w.Header().Set("Content-Type", "text/html;charset=utf-8") w.WriteHeader(403) e := RenderTemplateAlias("error", "security_error", w, r, pi.Header, pi) if e != nil { LogError(e) } return HandledRouteError() } var microNotFoundBytes = []byte("file not found") func MicroNotFound(w http.ResponseWriter, r *http.Request) RouteError { w.Header().Set("Content-Type", "text/html;charset=utf-8") w.WriteHeader(404) _, _ = w.Write(microNotFoundBytes) return HandledRouteError() } // NotFound is used when the requested page doesn't exist // ? - Add a JSQ version of this? // ? - Add a user parameter? func NotFound(w http.ResponseWriter, r *http.Request, h *Header) RouteError { return CustomError(p.GetErrorPhrase("not_found_body"), 404, p.GetErrorPhrase("not_found_title"), w, r, h, &GuestUser) } // ? - Add a user parameter? func NotFoundJS(w http.ResponseWriter, r *http.Request) RouteError { w.WriteHeader(404) writeJsonError(p.GetErrorPhrase("not_found_body"), w) return HandledRouteError() } func NotFoundJSQ(w http.ResponseWriter, r *http.Request, h *Header, js bool) RouteError { if js { return NotFoundJS(w, r) } if h == nil { h = DefaultHeader(w, &GuestUser) } return NotFound(w, r, h) } // CustomError lets us make custom error types which aren't covered by the generic functions above func CustomError(errmsg string, errcode int, errtitle string, w http.ResponseWriter, r *http.Request, h *Header, u *User) (rerr RouteError) { if h == nil { h, rerr = UserCheck(w, r, u) if rerr != nil { h = errorHeader(w, u, errtitle) } } h.Title = errtitle h.Zone = "error" pi := ErrorPage{h, errmsg} handleErrorTemplate(w, r, pi, errcode) return HandledRouteError() } // CustomErrorJSQ is a version of CustomError which lets us handle both JSON and regular pages depending on how it's being accessed func CustomErrorJSQ(errmsg string, errcode int, errtitle string, w http.ResponseWriter, r *http.Request, h *Header, u *User, js bool) RouteError { if !js { return CustomError(errmsg, errcode, errtitle, w, r, h, u) } return CustomErrorJS(errmsg, errcode, w, r, u) } // CustomErrorJS is the pure JSON version of CustomError func CustomErrorJS(errmsg string, errcode int, w http.ResponseWriter, r *http.Request, u *User) RouteError { w.WriteHeader(errcode) writeJsonError(errmsg, w) return HandledRouteError() } // TODO: Should we optimise this by caching these json strings? func writeJsonError(errmsg string, w http.ResponseWriter) { _, _ = w.Write([]byte(`{"errmsg":"` + strings.Replace(errmsg, "\"", "", -1) + `"}`)) } func handleErrorTemplate(w http.ResponseWriter, r *http.Request, pi ErrorPage, errcode int) { w.Header().Set("Content-Type", "text/html;charset=utf-8") w.WriteHeader(errcode) err := RenderTemplateAlias("error", "error", w, r, pi.Header, pi) if err != nil { LogError(err) } } // Alias of routes.renderTemplate var RenderTemplateAlias func(tmplName, hookName string, w http.ResponseWriter, r *http.Request, h *Header, pi interface{}) error ================================================ FILE: common/extend.go ================================================ /* * * Gosora Plugin System * Copyright Azareal 2016 - 2021 * */ package common // TODO: Break this file up into multiple files to make it easier to maintain import ( "database/sql" "errors" "log" "net/http" "sync" "sync/atomic" qgen "github.com/Azareal/Gosora/query_gen" ) var ErrPluginNotInstallable = errors.New("This plugin is not installable") type PluginList map[string]*Plugin // TODO: Have a proper store rather than a map? var Plugins PluginList = make(map[string]*Plugin) func (l PluginList) Add(pl *Plugin) { buildPlugin(pl) l[pl.UName] = pl } func buildPlugin(pl *Plugin) { pl.Installable = (pl.Install != nil) /* The Active field should never be altered by a plugin. It's used internally by the software to determine whether an admin has enabled a plugin or not and whether to run it. This will be overwritten by the user's preference. */ pl.Active = false pl.Installed = false pl.Hooks = make(map[string]int) pl.Data = nil } var hookTableBox atomic.Value // ! HookTable is a work in progress, do not use it yet // TODO: Test how fast it is to indirect hooks off the hook table as opposed to using them normally or using an interface{} for the hooks // TODO: Can we filter the HookTable for each request down to only hooks the request actually uses? // TODO: Make the RunXHook functions methods on HookTable // TODO: Have plugins update hooks on a mutex guarded map and create a copy of that map in a serial global goroutine which gets thrown in the atomic.Value type HookTable struct { //Hooks map[string][]func(interface{}) interface{} HooksNoRet map[string][]func(interface{}) HooksSkip map[string][]func(interface{}) bool Vhooks map[string]func(...interface{}) interface{} VhookSkippable_ map[string]func(...interface{}) (bool, RouteError) Sshooks map[string][]func(string) string PreRenderHooks map[string][]func(http.ResponseWriter, *http.Request, *User, interface{}) bool // For future use: //messageHooks map[string][]func(Message, PageInt, ...interface{}) interface{} } func init() { RebuildHookTable() } // For extend.go use only, access this via GetHookTable() elsewhere var hookTable = &HookTable{ //map[string][]func(interface{}) interface{}{}, map[string][]func(interface{}){ "forums_frow_assign": nil, //hg }, map[string][]func(interface{}) bool{ "topic_create_frow_assign": nil, //hg }, map[string]func(...interface{}) interface{}{ //"convo_post_update":nil, //"convo_post_create":nil, ///"forum_trow_assign": nil, "topics_topic_row_assign": nil, //"topics_user_row_assign": nil, "topic_reply_row_assign": nil, "create_group_preappend": nil, // What is this? Investigate! "topic_create_pre_loop": nil, "router_end": nil, }, map[string]func(...interface{}) (bool, RouteError){ "simple_forum_check_pre_perms": nil, //hg "forum_check_pre_perms": nil, //hg "route_topic_list_start": nil, "route_topic_list_mostviewed_start": nil, "route_forum_list_start": nil, "route_attach_start": nil, "route_attach_post_get": nil, "action_end_create_topic": nil, "action_end_edit_topic": nil, "action_end_delete_topic": nil, "action_end_lock_topic": nil, "action_end_unlock_topic": nil, "action_end_stick_topic": nil, "action_end_unstick_topic": nil, "action_end_move_topic": nil, "action_end_like_topic": nil, "action_end_unlike_topic": nil, "action_end_create_reply": nil, "action_end_edit_reply": nil, "action_end_delete_reply": nil, "action_end_add_attach_to_reply": nil, "action_end_remove_attach_from_reply": nil, "action_end_like_reply": nil, "action_end_unlike_reply": nil, "action_end_ban_user": nil, "action_end_unban_user": nil, "action_end_activate_user": nil, "router_after_filters": nil, "router_pre_route": nil, "tasks_tick_topic_list": nil, "tasks_tick_widget_wol": nil, "counters_perf_tick_row": nil, }, map[string][]func(string) string{ "preparse_preassign": nil, "parse_assign": nil, "topic_ogdesc_assign": nil, }, nil, //nil, } var hookTableUpdateMutex sync.Mutex func RebuildHookTable() { hookTableUpdateMutex.Lock() defer hookTableUpdateMutex.Unlock() unsafeRebuildHookTable() } func unsafeRebuildHookTable() { ihookTable := new(HookTable) *ihookTable = *hookTable hookTableBox.Store(ihookTable) } func GetHookTable() *HookTable { return hookTableBox.Load().(*HookTable) } // Hooks with a single argument. Is this redundant? Might be useful for inlining, as variadics aren't inlined? Are closures even inlined to begin with? /*func (t *HookTable) Hook(name string, data interface{}) interface{} { for _, hook := range t.Hooks[name] { data = hook(data) } return data }*/ func (t *HookTable) HookNoRet(name string, data interface{}) { for _, hook := range t.HooksNoRet[name] { hook(data) } } // To cover the case in routes/topic.go's CreateTopic route, we could probably obsolete this use and replace it func (t *HookTable) HookSkip(name string, data interface{}) (skip bool) { for _, hook := range t.HooksSkip[name] { if skip = hook(data); skip { break } } return skip } // Hooks with a variable number of arguments // TODO: Use RunHook semantics to allow multiple lined up plugins / modules their turn? func (t *HookTable) Vhook(name string, data ...interface{}) interface{} { if hook := t.Vhooks[name]; hook != nil { return hook(data...) } return nil } func (t *HookTable) VhookNoRet(name string, data ...interface{}) { if hook := t.Vhooks[name]; hook != nil { _ = hook(data...) } } // TODO: Find a better way of doing this func (t *HookTable) VhookNeedHook(name string, data ...interface{}) (ret interface{}, hasHook bool) { if hook := t.Vhooks[name]; hook != nil { return hook(data...), true } return nil, false } // Hooks with a variable number of arguments and return values for skipping the parent function and propagating an error upwards func (t *HookTable) VhookSkippable(name string, data ...interface{}) (bool, RouteError) { if hook := t.VhookSkippable_[name]; hook != nil { return hook(data...) } return false, nil } /*func VhookSkippableTest(t *HookTable, name string, data ...interface{}) (bool, RouteError) { if hook := t.VhookSkippable_[name]; hook != nil { return hook(data...) } return false, nil } func forum_check_pre_perms_hook(t *HookTable, w http.ResponseWriter, r *http.Request, u *User, fid *int, h *Header) (bool, RouteError) { hook := t.VhookSkippable_["forum_check_pre_perms"] if hook != nil { return hook(w, r, u, fid, h) } return false, nil }*/ // Hooks which take in and spit out a string. This is usually used for parser components // Trying to get a teeny bit of type-safety where-ever possible, especially for such a critical set of hooks func (t *HookTable) Sshook(name, data string) string { for _, hook := range t.Sshooks[name] { data = hook(data) } return data } //var vhookErrorable = map[string]func(...interface{}) (interface{}, RouteError){} var taskHooks = map[string][]func() error{ "before_half_second_tick": nil, "after_half_second_tick": nil, "before_second_tick": nil, "after_second_tick": nil, "before_fifteen_minute_tick": nil, "after_fifteen_minute_tick": nil, "before_shutdown_tick": nil, "after_shutdown_tick": nil, } // Coming Soon: type Message interface { ID() int Poster() int Contents() string ParsedContents() string } // While the idea is nice, this might result in too much code duplication, as we have seventy billion page structs, what else could we do to get static typing with these in plugins? type PageInt interface { Title() string Header() *Header CurrentUser() *User GetExtData(name string) interface{} SetExtData(name string, contents interface{}) } // Coming Soon: var messageHooks = map[string][]func(Message, PageInt, ...interface{}) interface{}{ "topic_reply_row_assign": nil, } // The hooks which run before the template is rendered for a route var PreRenderHooks = map[string][]func(http.ResponseWriter, *http.Request, *User, interface{}) bool{ "pre_render": nil, "pre_render_forums": nil, "pre_render_forum": nil, "pre_render_topics": nil, "pre_render_topic": nil, "pre_render_profile": nil, "pre_render_custom_page": nil, "pre_render_tmpl_page": nil, "pre_render_overview": nil, "pre_render_create_topic": nil, "pre_render_account_own_edit": nil, "pre_render_account_own_edit_password": nil, "pre_render_account_own_edit_mfa": nil, "pre_render_account_own_edit_mfa_setup": nil, "pre_render_account_own_edit_email": nil, "pre_render_level_list": nil, "pre_render_login": nil, "pre_render_login_mfa_verify": nil, "pre_render_register": nil, "pre_render_ban": nil, "pre_render_ip_search": nil, "pre_render_panel_dashboard": nil, "pre_render_panel_forums": nil, "pre_render_panel_delete_forum": nil, "pre_render_panel_forum_edit": nil, "pre_render_panel_forum_edit_perms": nil, "pre_render_panel_analytics_views": nil, "pre_render_panel_analytics_routes": nil, "pre_render_panel_analytics_agents": nil, "pre_render_panel_analytics_systems": nil, "pre_render_panel_analytics_referrers": nil, "pre_render_panel_analytics_route_views": nil, "pre_render_panel_analytics_agent_views": nil, "pre_render_panel_analytics_system_views": nil, "pre_render_panel_analytics_referrer_views": nil, "pre_render_panel_settings": nil, "pre_render_panel_setting": nil, "pre_render_panel_word_filters": nil, "pre_render_panel_word_filters_edit": nil, "pre_render_panel_plugins": nil, "pre_render_panel_users": nil, "pre_render_panel_user_edit": nil, "pre_render_panel_groups": nil, "pre_render_panel_group_edit": nil, "pre_render_panel_group_edit_perms": nil, "pre_render_panel_themes": nil, "pre_render_panel_modlogs": nil, "pre_render_error": nil, // Note: This hook isn't run for a few errors whose templates are computed at startup and reused, such as InternalError. This hook is also not available in JS mode. // ^-- I don't know if it's run for InternalError, but it isn't computed at startup anymore "pre_render_security_error": nil, } // ? - Should we make this an interface which plugins implement instead? // Plugin is a struct holding the metadata for a plugin, along with a few of it's primary handlers. type Plugin struct { UName string Name string Author string URL string Settings string Active bool Tag string Type string Installable bool Installed bool Init func(pl *Plugin) error Activate func(pl *Plugin) error Deactivate func(pl *Plugin) // TODO: We might want to let this return an error? Install func(pl *Plugin) error Uninstall func(pl *Plugin) error // TODO: I'm not sure uninstall is implemented Hooks map[string]int // Active hooks Meta PluginMetaData Data interface{} // Usually used for hosting the VMs / reusable elements of non-native plugins } type PluginMetaData struct { Hooks []string //StaticHooks map[string]string } func (pl *Plugin) BypassActive() (active bool, err error) { err = extendStmts.isActive.QueryRow(pl.UName).Scan(&active) if err != nil && err != sql.ErrNoRows { return false, err } return active, nil } func (pl *Plugin) InDatabase() (exists bool, err error) { var sink bool err = extendStmts.isActive.QueryRow(pl.UName).Scan(&sink) if err != nil && err != sql.ErrNoRows { return false, err } return err == nil, nil } // TODO: Silently add to the database, if it doesn't exist there rather than forcing users to call AddToDatabase instead? func (pl *Plugin) SetActive(active bool) (err error) { _, err = extendStmts.setActive.Exec(active, pl.UName) if err == nil { pl.Active = active } return err } // TODO: Silently add to the database, if it doesn't exist there rather than forcing users to call AddToDatabase instead? func (pl *Plugin) SetInstalled(installed bool) (err error) { if !pl.Installable { return ErrPluginNotInstallable } _, err = extendStmts.setInstalled.Exec(installed, pl.UName) if err == nil { pl.Installed = installed } return err } func (pl *Plugin) AddToDatabase(active, installed bool) (err error) { _, err = extendStmts.add.Exec(pl.UName, active, installed) if err == nil { pl.Active = active pl.Installed = installed } return err } type ExtendStmts struct { getPlugins *sql.Stmt isActive *sql.Stmt setActive *sql.Stmt setInstalled *sql.Stmt add *sql.Stmt } var extendStmts ExtendStmts func init() { DbInits.Add(func(acc *qgen.Accumulator) error { pl := "plugins" extendStmts = ExtendStmts{ getPlugins: acc.Select(pl).Columns("uname,active,installed").Prepare(), isActive: acc.Select(pl).Columns("active").Where("uname=?").Prepare(), setActive: acc.Update(pl).Set("active=?").Where("uname=?").Prepare(), setInstalled: acc.Update(pl).Set("installed=?").Where("uname=?").Prepare(), add: acc.Insert(pl).Columns("uname,active,installed").Fields("?,?,?").Prepare(), } return acc.FirstError() }) } func InitExtend() error { err := InitPluginLangs() if err != nil { return err } return Plugins.Load() } // Load polls the database to see which plugins have been activated and which have been installed func (l PluginList) Load() error { rows, err := extendStmts.getPlugins.Query() if err != nil { return err } defer rows.Close() var uname string var active, installed bool for rows.Next() { err = rows.Scan(&uname, &active, &installed) if err != nil { return err } // Was the plugin deleted at some point? pl, ok := l[uname] if !ok { continue } pl.Active = active pl.Installed = installed l[uname] = pl } return rows.Err() } // ? - Is this racey? // TODO: Generate the cases in this switch func (pl *Plugin) AddHook(name string, hInt interface{}) { hookTableUpdateMutex.Lock() defer hookTableUpdateMutex.Unlock() switch h := hInt.(type) { /*case func(interface{}) interface{}: if len(hookTable.Hooks[name]) == 0 { hookTable.Hooks[name] = []func(interface{}) interface{}{} } hookTable.Hooks[name] = append(hookTable.Hooks[name], h) pl.Hooks[name] = len(hookTable.Hooks[name]) - 1*/ case func(interface{}): if len(hookTable.HooksNoRet[name]) == 0 { hookTable.HooksNoRet[name] = []func(interface{}){} } hookTable.HooksNoRet[name] = append(hookTable.HooksNoRet[name], h) pl.Hooks[name] = len(hookTable.HooksNoRet[name]) - 1 case func(interface{}) bool: if len(hookTable.HooksSkip[name]) == 0 { hookTable.HooksSkip[name] = []func(interface{}) bool{} } hookTable.HooksSkip[name] = append(hookTable.HooksSkip[name], h) pl.Hooks[name] = len(hookTable.HooksSkip[name]) - 1 case func(string) string: if len(hookTable.Sshooks[name]) == 0 { hookTable.Sshooks[name] = []func(string) string{} } hookTable.Sshooks[name] = append(hookTable.Sshooks[name], h) pl.Hooks[name] = len(hookTable.Sshooks[name]) - 1 case func(http.ResponseWriter, *http.Request, *User, interface{}) bool: if len(PreRenderHooks[name]) == 0 { PreRenderHooks[name] = []func(http.ResponseWriter, *http.Request, *User, interface{}) bool{} } PreRenderHooks[name] = append(PreRenderHooks[name], h) pl.Hooks[name] = len(PreRenderHooks[name]) - 1 case func() error: // ! We might want a more generic name, as we might use this signature for things other than tasks hooks if len(taskHooks[name]) == 0 { taskHooks[name] = []func() error{} } taskHooks[name] = append(taskHooks[name], h) pl.Hooks[name] = len(taskHooks[name]) - 1 case func(...interface{}) interface{}: hookTable.Vhooks[name] = h pl.Hooks[name] = 0 case func(...interface{}) (bool, RouteError): hookTable.VhookSkippable_[name] = h pl.Hooks[name] = 0 default: panic("I don't recognise this kind of handler!") // Should this be an error for the plugin instead of a panic()? } // TODO: Do this once during plugin activation / deactivation rather than doing it for each hook unsafeRebuildHookTable() } // ? - Is this racey? // TODO: Generate the cases in this switch func (pl *Plugin) RemoveHook(name string, hInt interface{}) { hookTableUpdateMutex.Lock() defer hookTableUpdateMutex.Unlock() key, ok := pl.Hooks[name] if !ok { panic("handler not registered as hook") } switch hInt.(type) { /*case func(interface{}) interface{}: hook := hookTable.Hooks[name] if len(hook) == 1 { hook = []func(interface{}) interface{}{} } else { hook = append(hook[:key], hook[key+1:]...) } hookTable.Hooks[name] = hook*/ case func(interface{}): hook := hookTable.HooksNoRet[name] if len(hook) == 1 { hook = []func(interface{}){} } else { hook = append(hook[:key], hook[key+1:]...) } hookTable.HooksNoRet[name] = hook case func(interface{}) bool: hook := hookTable.HooksSkip[name] if len(hook) == 1 { hook = []func(interface{}) bool{} } else { hook = append(hook[:key], hook[key+1:]...) } hookTable.HooksSkip[name] = hook case func(string) string: hook := hookTable.Sshooks[name] if len(hook) == 1 { hook = []func(string) string{} } else { hook = append(hook[:key], hook[key+1:]...) } hookTable.Sshooks[name] = hook case func(http.ResponseWriter, *http.Request, *User, interface{}) bool: hook := PreRenderHooks[name] if len(hook) == 1 { hook = []func(http.ResponseWriter, *http.Request, *User, interface{}) bool{} } else { hook = append(hook[:key], hook[key+1:]...) } PreRenderHooks[name] = hook case func() error: hook := taskHooks[name] if len(hook) == 1 { hook = []func() error{} } else { hook = append(hook[:key], hook[key+1:]...) } taskHooks[name] = hook case func(...interface{}) interface{}: delete(hookTable.Vhooks, name) case func(...interface{}) (bool, RouteError): delete(hookTable.VhookSkippable_, name) default: panic("I don't recognise this kind of handler!") // Should this be an error for the plugin instead of a panic()? } delete(pl.Hooks, name) // TODO: Do this once during plugin activation / deactivation rather than doing it for each hook unsafeRebuildHookTable() } // TODO: Add a HasHook method to complete the AddHook, RemoveHook, etc. set? var PluginsInited = false func InitPlugins() { for name, body := range Plugins { log.Printf("Added plugin '%s'", name) if body.Active { log.Printf("Initialised plugin '%s'", name) if body.Init != nil { if err := body.Init(body); err != nil { log.Print(err) } } else { log.Printf("Plugin '%s' doesn't have an initialiser.", name) } } } PluginsInited = true } // ? - Are the following functions racey? func RunTaskHook(name string) error { for _, hook := range taskHooks[name] { if e := hook(); e != nil { return e } } return nil } func RunPreRenderHook(name string, w http.ResponseWriter, r *http.Request, u *User, data interface{}) (halt bool) { // This hook runs on ALL PreRender hooks preRenderHooks, ok := PreRenderHooks["pre_render"] if ok { for _, hook := range preRenderHooks { if hook(w, r, u, data) { return true } } } // The actual PreRender hook preRenderHooks, ok = PreRenderHooks[name] if ok { for _, hook := range preRenderHooks { if hook(w, r, u, data) { return true } } } return false } ================================================ FILE: common/files.go ================================================ package common import ( "bytes" "compress/gzip" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "fmt" "io/ioutil" "mime" "net/http" "net/url" "os" "path/filepath" "strconv" "strings" "sync" tmpl "github.com/Azareal/Gosora/tmpl_client" "github.com/andybalholm/brotli" ) //type SFileList map[string]*SFile //type SFileListShort map[string]*SFile var StaticFiles = SFileList{"/s/", make(map[string]*SFile), make(map[string]*SFile)} //var StaticFilesShort SFileList = make(map[string]*SFile) var staticFileMutex sync.RWMutex // ? Is it efficient to have two maps for this? type SFileList struct { Prefix string Long map[string]*SFile Short map[string]*SFile } type SFile struct { // TODO: Move these to the end? Data []byte GzipData []byte BrData []byte Sha256 string Sha256I string OName string Pos int64 Length int64 StrLength string GzipLength int64 StrGzipLength string BrLength int64 StrBrLength string Mimetype string Info os.FileInfo FormattedModTime string } type CSSData struct { Phrases map[string]string } func (l SFileList) JSTmplInit() error { DebugLog("Initialising the client side templates") return filepath.Walk("./tmpl_client", func(path string, f os.FileInfo, err error) error { if f.IsDir() || strings.HasSuffix(path, "tmpl_list.go") || strings.HasSuffix(path, "stub.go") { return nil } path = strings.Replace(path, "\\", "/", -1) DebugLog("Processing client template " + path) data, err := ioutil.ReadFile(path) if err != nil { return err } path = strings.TrimPrefix(path, "tmpl_client/") tmplName := strings.TrimSuffix(path, ".jgo") shortName := strings.TrimPrefix(tmplName, "tmpl_") replace := func(data []byte, replaceThis, withThis string) []byte { return bytes.Replace(data, []byte(replaceThis), []byte(withThis), -1) } rep := func(replaceThis, withThis string) { data = replace(data, replaceThis, withThis) } startIndex, hasFunc := skipAllUntilCharsExist(data, 0, []byte("if(tmplInits===undefined)")) if !hasFunc { return errors.New("no init map found") } data = data[startIndex-len([]byte("if(tmplInits===undefined)")):] rep("// nolint", "") //rep("func ", "function ") rep("func ", "function ") rep(" error {\n", " {\nlet o=\"\"\n") funcIndex, hasFunc := skipAllUntilCharsExist(data, 0, []byte("function Tmpl_")) if !hasFunc { return errors.New("no template function found") } spaceIndex, hasSpace := skipUntilIfExists(data, funcIndex, ' ') if !hasSpace { return errors.New("no spaces found after the template function name") } endBrace, hasBrace := skipUntilIfExists(data, spaceIndex, ')') if !hasBrace { return errors.New("no right brace found after the template function name") } fmt.Println("spaceIndex: ", spaceIndex) fmt.Println("endBrace: ", endBrace) fmt.Println("string(data[spaceIndex:endBrace]): ", string(data[spaceIndex:endBrace])) preLen := len(data) rep(string(data[spaceIndex:endBrace]), "") rep("))\n", " \n") endBrace -= preLen - len(data) // Offset it as we've deleted portions fmt.Println("new endBrace: ", endBrace) fmt.Println("data: ", string(data)) /*showPos := func(data []byte, index int) (out string) { out = "[" for j, char := range data { if index == j { out += "[" + string(char) + "] " } else { out += string(char) + " " } } return out + "]" }*/ // ? Can we just use a regex? I'm thinking of going more efficient, or just outright rolling wasm, this is a temp hack in a place where performance doesn't particularly matter each := func(phrase string, h func(index int)) { //fmt.Println("find each '" + phrase + "'") index := endBrace if index < 0 { panic("index under zero: " + strconv.Itoa(index)) } var foundIt bool for { //fmt.Println("in index: ", index) //fmt.Println("pos: ", showPos(data, index)) index, foundIt = skipAllUntilCharsExist(data, index, []byte(phrase)) if !foundIt { break } h(index) } } each("strconv.Itoa(", func(index int) { braceAt, hasEndBrace := skipUntilIfExistsOrLine(data, index, ')') if hasEndBrace { data[braceAt] = ' ' // Blank it } }) each("[]byte(", func(index int) { braceAt, hasEndBrace := skipUntilIfExistsOrLine(data, index, ')') if hasEndBrace { data[braceAt] = ' ' // Blank it } }) each("StringToBytes(", func(index int) { braceAt, hasEndBrace := skipUntilIfExistsOrLine(data, index, ')') if hasEndBrace { data[braceAt] = ' ' // Blank it } }) each("w.Write(", func(index int) { braceAt, hasEndBrace := skipUntilIfExistsOrLine(data, index, ')') if hasEndBrace { data[braceAt] = ' ' // Blank it } }) each("RelativeTime(", func(index int) { braceAt, _ := skipUntilIfExistsOrLine(data, index, 10) if data[braceAt-1] == ' ' { data[braceAt-1] = ' ' // Blank it } }) each("if ", func(index int) { //fmt.Println("if index: ", index) braceAt, hasBrace := skipUntilIfExistsOrLine(data, index, '{') if hasBrace { if data[braceAt-1] != ' ' { panic("couldn't find space before brace, found ' " + string(data[braceAt-1]) + "' instead") } data[braceAt-1] = ')' // Drop a brace here to satisfy JS } }) each("for _, item := range ", func(index int) { //fmt.Println("for index: ", index) braceAt, hasBrace := skipUntilIfExists(data, index, '{') if hasBrace { if data[braceAt-1] != ' ' { panic("couldn't find space before brace, found ' " + string(data[braceAt-1]) + "' instead") } data[braceAt-1] = ')' // Drop a brace here to satisfy JS } }) rep("for _, item := range ", "for(item of ") rep("w.Write([]byte(", "o += ") rep("w.Write(StringToBytes(", "o += ") rep("w.Write(", "o += ") rep("+= c.", "+= ") rep("strconv.Itoa(", "") rep("strconv.FormatInt(", "") rep(" c.", "") rep("phrases.", "") rep(", 10;", "") //rep("var plist = GetTmplPhrasesBytes("+shortName+"_tmpl_phrase_id)", "const plist = tmplPhrases[\""+tmplName+"\"];") //rep("//var plist = GetTmplPhrasesBytes("+shortName+"_tmpl_phrase_id)", "const "+shortName+"_phrase_arr = tmplPhrases[\""+tmplName+"\"];") rep("//var plist = GetTmplPhrasesBytes("+shortName+"_tmpl_phrase_id)", "const pl=tmplPhrases[\""+tmplName+"\"];") rep(shortName+"_phrase_arr", "pl") rep(shortName+"_phrase", "pl") rep("tmpl_"+shortName+"_vars", "t_v") rep("var c_v_", "let c_v_") rep(`t_vars, ok := tmpl_i.`, `/*`) rep("[]byte(", "") rep("StringToBytes(", "") rep("RelativeTime(t_v.", "t_v.Relative") // TODO: Format dates properly on the client side rep(".Format(\"2006-01-02 15:04:05\"", "") rep(", 10", "") rep("if ", "if(") rep("return nil", "return o") rep(" )", ")") rep(" \n", "\n") rep("\n", ";\n") rep("{;", "{") rep("};", "}") rep("[;", "[") rep(",;", ",") rep("=;", "=") rep(`, }); }`, "\n\t];") rep(`= }`, "=[]") rep("o += ", "o+=") rep(shortName+"_frags[", "fr[") rep("function Tmpl_"+shortName+"(t_v) {", "var Tmpl_"+shortName+"=(t_v)=>{") fragset := tmpl.GetFrag(shortName) if fragset != nil { //sfrags := []byte("let " + shortName + "_frags=[\n") sfrags := []byte("{const fr=[") for i, frags := range fragset { //sfrags = append(sfrags, []byte(shortName+"_frags.push(`"+string(frags)+"`);\n")...) //sfrags = append(sfrags, []byte("`"+string(frags)+"`,\n")...) if i == 0 { sfrags = append(sfrags, []byte("`"+string(frags)+"`")...) } else { sfrags = append(sfrags, []byte(",`"+string(frags)+"`")...) } } //sfrags = append(sfrags, []byte("];\n")...) sfrags = append(sfrags, []byte("];")...) data = append(sfrags, data...) } rep("\n;", "\n") rep(";;", ";") data = append(data, '}') for name, _ := range Themes { if strings.HasSuffix(shortName, "_"+name) { data = append(data, "var Tmpl_"+strings.TrimSuffix(shortName, "_"+name)+"=Tmpl_"+shortName+";"...) break } } path = tmplName + ".js" DebugLog("js path: ", path) ext := filepath.Ext("/tmpl_client/" + path) brData, err := CompressBytesBrotli(data) if err != nil { return err } // Don't use Brotli if we get meagre gains from it as it takes longer to process the responses if len(brData) >= (len(data) + 110) { brData = nil } else { diff := len(data) - len(brData) if diff <= len(data)/100 { brData = nil } } gzipData, err := CompressBytesGzip(data) if err != nil { return err } // Don't use Gzip if we get meagre gains from it as it takes longer to process the responses if len(gzipData) >= (len(data) + 120) { gzipData = nil } else { diff := len(data) - len(gzipData) if diff <= len(data)/100 { gzipData = nil } } // Get a checksum for CSPs and cache busting hasher := sha256.New() hasher.Write(data) sum := hasher.Sum(nil) checksum := hex.EncodeToString(sum) integrity := base64.StdEncoding.EncodeToString(sum) l.Set(l.Prefix+path, &SFile{data, gzipData, brData, checksum, integrity, l.Prefix + path + "?h=" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)}) DebugLogf("Added the '%s' static file.", path) return nil }) } func (l SFileList) Init() error { return filepath.Walk("./public", func(path string, f os.FileInfo, err error) error { if f.IsDir() { return nil } path = strings.Replace(path, "\\", "/", -1) data, err := ioutil.ReadFile(path) if err != nil { return err } path = strings.TrimPrefix(path, "public/") ext := filepath.Ext("/public/" + path) if ext == ".js" { data = bytes.Replace(data, []byte("\r"), []byte(""), -1) } mimetype := mime.TypeByExtension(ext) // Get a checksum for CSPs and cache busting hasher := sha256.New() hasher.Write(data) sum := hasher.Sum(nil) checksum := hex.EncodeToString(sum) integrity := base64.StdEncoding.EncodeToString(sum) // Avoid double-compressing images var gzipData, brData []byte if mimetype != "image/jpeg" && mimetype != "image/png" && mimetype != "image/gif" { brData, err = CompressBytesBrotli(data) if err != nil { return err } // Don't use Brotli if we get meagre gains from it as it takes longer to process the responses if len(brData) >= (len(data) + 130) { brData = nil } else { diff := len(data) - len(brData) if diff <= len(data)/100 { brData = nil } } gzipData, err = CompressBytesGzip(data) if err != nil { return err } // Don't use Gzip if we get meagre gains from it as it takes longer to process the responses if len(gzipData) >= (len(data) + 150) { gzipData = nil } else { diff := len(data) - len(gzipData) if diff <= len(data)/100 { gzipData = nil } } } l.Set(l.Prefix+path, &SFile{data, gzipData, brData, checksum, integrity, l.Prefix + path + "?h=" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mimetype, f, f.ModTime().UTC().Format(http.TimeFormat)}) DebugLogf("Added the '%s' static file.", path) return nil }) } func (l SFileList) Add(path, prefix string) error { data, err := ioutil.ReadFile(path) if err != nil { return err } fi, err := os.Open(path) if err != nil { return err } f, err := fi.Stat() if err != nil { return err } ext := filepath.Ext(path) path = strings.TrimPrefix(path, prefix) brData, err := CompressBytesBrotli(data) if err != nil { return err } // Don't use Brotli if we get meagre gains from it as it takes longer to process the responses if len(brData) >= (len(data) + 130) { brData = nil } else { diff := len(data) - len(brData) if diff <= len(data)/100 { brData = nil } } gzipData, err := CompressBytesGzip(data) if err != nil { return err } // Don't use Gzip if we get meagre gains from it as it takes longer to process the responses if len(gzipData) >= (len(data) + 150) { gzipData = nil } else { diff := len(data) - len(gzipData) if diff <= len(data)/100 { gzipData = nil } } // Get a checksum for CSPs and cache busting hasher := sha256.New() hasher.Write(data) sum := hasher.Sum(nil) checksum := hex.EncodeToString(sum) integrity := base64.StdEncoding.EncodeToString(sum) l.Set(l.Prefix+path, &SFile{data, gzipData, brData, checksum, integrity, l.Prefix + path + "?h=" + checksum, 0, int64(len(data)), strconv.Itoa(len(data)), int64(len(gzipData)), strconv.Itoa(len(gzipData)), int64(len(brData)), strconv.Itoa(len(brData)), mime.TypeByExtension(ext), f, f.ModTime().UTC().Format(http.TimeFormat)}) DebugLogf("Added the '%s' static file", path) return nil } func (l SFileList) Get(path string) (file *SFile, exists bool) { staticFileMutex.RLock() defer staticFileMutex.RUnlock() file, exists = l.Long[path] return file, exists } // fetch without /s/ to avoid allocing in pages.go func (l SFileList) GetShort(name string) (file *SFile, exists bool) { staticFileMutex.RLock() defer staticFileMutex.RUnlock() file, exists = l.Short[name] return file, exists } func (l SFileList) Set(name string, data *SFile) { staticFileMutex.Lock() defer staticFileMutex.Unlock() // TODO: Propagate errors back up uurl, err := url.Parse(name) if err != nil { return } l.Long[uurl.Path] = data l.Short[strings.TrimPrefix(strings.TrimPrefix(name, l.Prefix), "/")] = data } var gzipBestCompress sync.Pool func CompressBytesGzip(in []byte) (b []byte, err error) { var buf bytes.Buffer ii := gzipBestCompress.Get() var gz *gzip.Writer if ii == nil { gz, err = gzip.NewWriterLevel(&buf, gzip.BestCompression) if err != nil { return nil, err } } else { gz = ii.(*gzip.Writer) gz.Reset(&buf) } _, err = gz.Write(in) if err != nil { return nil, err } err = gz.Close() if err != nil { return nil, err } gzipBestCompress.Put(gz) return buf.Bytes(), nil } func CompressBytesBrotli(in []byte) ([]byte, error) { var buff bytes.Buffer br := brotli.NewWriterLevel(&buff, brotli.BestCompression) _, err := br.Write(in) if err != nil { return nil, err } err = br.Close() if err != nil { return nil, err } return buff.Bytes(), nil } ================================================ FILE: common/forum.go ================================================ package common import ( //"log" "database/sql" "errors" "strconv" "strings" qgen "github.com/Azareal/Gosora/query_gen" _ "github.com/go-sql-driver/mysql" ) // TODO: Do we really need this? type ForumAdmin struct { ID int Name string Desc string Active bool Preset string TopicCount int PresetLang string } type Forum struct { ID int Link string Name string Desc string Tmpl string Active bool Order int Preset string ParentID int ParentType string TopicCount int LastTopic *Topic LastTopicID int LastReplyer *User LastReplyerID int LastTopicTime string // So that we can re-calculate the relative time on the spot in /forums/ LastPage int } // ? - What is this for? type ForumSimple struct { ID int Name string Active bool Preset string } type ForumStmts struct { update *sql.Stmt setPreset *sql.Stmt } var forumStmts ForumStmts func init() { DbInits.Add(func(acc *qgen.Accumulator) error { forumStmts = ForumStmts{ update: acc.Update("forums").Set("name=?,desc=?,active=?,preset=?").Where("fid=?").Prepare(), setPreset: acc.Update("forums").Set("preset=?").Where("fid=?").Prepare(), } return acc.FirstError() }) } // Copy gives you a non-pointer concurrency safe copy of the forum func (f *Forum) Copy() (fcopy Forum) { fcopy = *f return fcopy } // TODO: Write tests for this func (f *Forum) Update(name, desc string, active bool, preset string) error { if name == "" { name = f.Name } // TODO: Do a line sanitise? Does it matter? preset = strings.TrimSpace(preset) _, err := forumStmts.update.Exec(name, desc, active, preset, f.ID) if err != nil { return err } if f.Preset != preset && preset != "custom" && preset != "" { err = PermmapToQuery(PresetToPermmap(preset), f.ID) if err != nil { return err } } _ = Forums.Reload(f.ID) return nil } func (f *Forum) SetPreset(preset string, gid int) error { fp, changed := GroupForumPresetToForumPerms(preset) if changed { return f.SetPerms(fp, preset, gid) } return nil } // TODO: Refactor this func (f *Forum) SetPerms(fperms *ForumPerms, preset string, gid int) (err error) { err = ReplaceForumPermsForGroup(gid, map[int]string{f.ID: preset}, map[int]*ForumPerms{f.ID: fperms}) if err != nil { LogError(err) return errors.New("Unable to update the permissions") } // TODO: Add this and replaceForumPermsForGroup into a transaction? _, err = forumStmts.setPreset.Exec("", f.ID) if err != nil { LogError(err) return errors.New("Unable to update the forum") } err = Forums.Reload(f.ID) if err != nil { return errors.New("Unable to reload forum") } err = FPStore.Reload(f.ID) if err != nil { return errors.New("Unable to reload the forum permissions") } return nil } // TODO: Replace this sorting mechanism with something a lot more efficient // ? - Use sort.Slice instead? type SortForum []*Forum func (sf SortForum) Len() int { return len(sf) } func (sf SortForum) Swap(i, j int) { sf[i], sf[j] = sf[j], sf[i] } /*func (sf SortForum) Less(i,j int) bool { l := sf.less(i,j) if l { log.Printf("%s is less than %s. order: %d. id: %d.",sf[i].Name, sf[j].Name, sf[i].Order, sf[i].ID) } else { log.Printf("%s is not less than %s. order: %d. id: %d.",sf[i].Name, sf[j].Name, sf[i].Order, sf[i].ID) } return l }*/ func (sf SortForum) Less(i, j int) bool { if sf[i].Order < sf[j].Order { return true } else if sf[i].Order == sf[j].Order { return sf[i].ID < sf[j].ID } return false } // ! Don't use this outside of tests and possibly template_init.go func BlankForum(fid int, link, name, desc string, active bool, preset string, parentID int, parentType string, topicCount int) *Forum { return &Forum{ID: fid, Link: link, Name: name, Desc: desc, Active: active, Preset: preset, ParentID: parentID, ParentType: parentType, TopicCount: topicCount} } func BuildForumURL(slug string, fid int) string { if slug == "" || !Config.BuildSlugs { return "/forum/" + strconv.Itoa(fid) } return "/forum/" + slug + "." + strconv.Itoa(fid) } func GetForumURLPrefix() string { return "/forum/" } ================================================ FILE: common/forum_actions.go ================================================ package common import ( "database/sql" "fmt" "strconv" qgen "github.com/Azareal/Gosora/query_gen" ) var ForumActionStore ForumActionStoreInt //var ForumActionRunnableStore ForumActionRunnableStoreInt const ( ForumActionDelete = iota ForumActionLock ForumActionUnlock ForumActionMove ) func ConvStringToAct(s string) int { switch s { case "delete": return ForumActionDelete case "lock": return ForumActionLock case "unlock": return ForumActionUnlock case "move": return ForumActionMove } return -1 } func ConvActToString(a int) string { switch a { case ForumActionDelete: return "delete" case ForumActionLock: return "lock" case ForumActionUnlock: return "unlock" case ForumActionMove: return "move" } return "" } var forumActionStmts ForumActionStmts type ForumActionStmts struct { get1 *sql.Stmt get2 *sql.Stmt lock1 *sql.Stmt lock2 *sql.Stmt unlock1 *sql.Stmt unlock2 *sql.Stmt } type ForumAction struct { ID int Forum int RunOnTopicCreation bool RunDaysAfterTopicCreation int RunDaysAfterTopicLastReply int Action int Extra string } func init() { DbInits.Add(func(acc *qgen.Accumulator) error { t := "topics" forumActionStmts = ForumActionStmts{ get1: acc.Select(t).Cols("tid,createdBy,poll").Where("parentID=?").DateOlderThanQ("createdAt", "day").Stmt(), get2: acc.Select(t).Cols("tid,createdBy,poll").Where("parentID=?").DateOlderThanQ("lastReplyAt", "day").Stmt(), /*lock1: acc.Update(t).Set("is_closed=1").Where("parentID=?").DateOlderThanQ("createdAt", "day").Stmt(), lock2: acc.Update(t).Set("is_closed=1").Where("parentID=?").DateOlderThanQ("lastReplyAt", "day").Stmt(), unlock1: acc.Update(t).Set("is_closed=0").Where("parentID=?").DateOlderThanQ("createdAt", "day").Stmt(), unlock2: acc.Update(t).Set("is_closed=0").Where("parentID=?").DateOlderThanQ("lastReplyAt", "day").Stmt(),*/ } return acc.FirstError() }) } func (a *ForumAction) Run() error { if a.RunDaysAfterTopicCreation > 0 { if e := a.runDaysAfterTopicCreation(); e != nil { return e } } if a.RunDaysAfterTopicLastReply > 0 { if e := a.runDaysAfterTopicLastReply(); e != nil { return e } } return nil } func (a *ForumAction) runQ(stmt *sql.Stmt, days int, f func(t *Topic) error) error { rows, e := stmt.Query(days, a.Forum) if e != nil { return e } defer rows.Close() for rows.Next() { // TODO: Decouple this t := &Topic{ParentID: a.Forum} if e := rows.Scan(&t.ID, &t.CreatedBy, &t.Poll); e != nil { return e } if e = f(t); e != nil { return e } } return rows.Err() } func (a *ForumAction) runDaysAfterTopicCreation() (e error) { switch a.Action { case ForumActionDelete: // TODO: Bulk delete? e = a.runQ(forumActionStmts.get1, a.RunDaysAfterTopicCreation, func(t *Topic) error { return t.Delete() }) case ForumActionLock: /*_, e := forumActionStmts.lock1.Exec(a.Forum) if e != nil { return e }*/ // TODO: Bulk lock? Lock and get resultset of changed topics somehow? fmt.Println("ForumActionLock") e = a.runQ(forumActionStmts.get1, a.RunDaysAfterTopicCreation, func(t *Topic) error { fmt.Printf("t: %+v\n", t) return t.Lock() }) case ForumActionUnlock: // TODO: Bulk unlock? Unlock and get resultset of changed topics somehow? e = a.runQ(forumActionStmts.get1, a.RunDaysAfterTopicCreation, func(t *Topic) error { return t.Unlock() }) case ForumActionMove: destForum, e := strconv.Atoi(a.Extra) if e != nil { return e } e = a.runQ(forumActionStmts.get1, a.RunDaysAfterTopicCreation, func(t *Topic) error { return t.MoveTo(destForum) }) } return e } func (a *ForumAction) runDaysAfterTopicLastReply() (e error) { switch a.Action { case ForumActionDelete: e = a.runQ(forumActionStmts.get2, a.RunDaysAfterTopicLastReply, func(t *Topic) error { return t.Delete() }) case ForumActionLock: // TODO: Bulk lock? Lock and get resultset of changed topics somehow? e = a.runQ(forumActionStmts.get2, a.RunDaysAfterTopicLastReply, func(t *Topic) error { return t.Lock() }) case ForumActionUnlock: // TODO: Bulk unlock? Unlock and get resultset of changed topics somehow? e = a.runQ(forumActionStmts.get2, a.RunDaysAfterTopicLastReply, func(t *Topic) error { return t.Unlock() }) case ForumActionMove: destForum, e := strconv.Atoi(a.Extra) if e != nil { return e } e = a.runQ(forumActionStmts.get2, a.RunDaysAfterTopicLastReply, func(t *Topic) error { return t.MoveTo(destForum) }) } return nil } func (a *ForumAction) TopicCreation(tid int) error { if !a.RunOnTopicCreation { return nil } return nil } type ForumActionStoreInt interface { Get(faid int) (*ForumAction, error) GetInForum(fid int) ([]*ForumAction, error) GetAll() ([]*ForumAction, error) GetNewTopicActions(fid int) ([]*ForumAction, error) Add(fa *ForumAction) (int, error) Delete(faid int) error Exists(faid int) bool Count() int CountInForum(fid int) int DailyTick() error } type DefaultForumActionStore struct { get *sql.Stmt getInForum *sql.Stmt getAll *sql.Stmt getNewTopicActions *sql.Stmt add *sql.Stmt delete *sql.Stmt exists *sql.Stmt count *sql.Stmt countInForum *sql.Stmt } func NewDefaultForumActionStore(acc *qgen.Accumulator) (*DefaultForumActionStore, error) { fa := "forums_actions" allCols := "faid,fid,runOnTopicCreation,runDaysAfterTopicCreation,runDaysAfterTopicLastReply,action,extra" return &DefaultForumActionStore{ get: acc.Select(fa).Columns("fid,runOnTopicCreation,runDaysAfterTopicCreation,runDaysAfterTopicLastReply,action,extra").Where("faid=?").Prepare(), getInForum: acc.Select(fa).Columns("faid,runOnTopicCreation,runDaysAfterTopicCreation,runDaysAfterTopicLastReply,action,extra").Where("fid=?").Prepare(), getAll: acc.Select(fa).Columns(allCols).Prepare(), getNewTopicActions: acc.Select(fa).Columns(allCols).Where("fid=? AND runOnTopicCreation=1").Prepare(), add: acc.Insert(fa).Columns("fid,runOnTopicCreation,runDaysAfterTopicCreation,runDaysAfterTopicLastReply,action,extra").Fields("?,?,?,?,?,?").Prepare(), delete: acc.Delete(fa).Where("faid=?").Prepare(), exists: acc.Exists(fa, "faid").Prepare(), count: acc.Count(fa).Prepare(), countInForum: acc.Count(fa).Where("fid=?").Prepare(), }, acc.FirstError() } func (s *DefaultForumActionStore) DailyTick() error { fas, e := s.GetAll() if e != nil { return e } for _, fa := range fas { if e := fa.Run(); e != nil { return e } } return nil } func (s *DefaultForumActionStore) Get(id int) (*ForumAction, error) { fa := ForumAction{ID: id} var str string e := s.get.QueryRow(id).Scan(&fa.Forum, &fa.RunOnTopicCreation, &fa.RunDaysAfterTopicCreation, &fa.RunDaysAfterTopicLastReply, &str, &fa.Extra) fa.Action = ConvStringToAct(str) return &fa, e } func (s *DefaultForumActionStore) GetInForum(fid int) (fas []*ForumAction, e error) { rows, e := s.getInForum.Query(fid) if e != nil { return nil, e } defer rows.Close() var str string for rows.Next() { fa := ForumAction{Forum: fid} if e := rows.Scan(&fa.ID, &fa.RunOnTopicCreation, &fa.RunDaysAfterTopicCreation, &fa.RunDaysAfterTopicLastReply, &str, &fa.Extra); e != nil { return nil, e } fa.Action = ConvStringToAct(str) fas = append(fas, &fa) } return fas, rows.Err() } func (s *DefaultForumActionStore) GetAll() (fas []*ForumAction, e error) { rows, e := s.getAll.Query() if e != nil { return nil, e } defer rows.Close() var str string for rows.Next() { fa := ForumAction{} if e := rows.Scan(&fa.ID, &fa.Forum, &fa.RunOnTopicCreation, &fa.RunDaysAfterTopicCreation, &fa.RunDaysAfterTopicLastReply, &str, &fa.Extra); e != nil { return nil, e } fa.Action = ConvStringToAct(str) fas = append(fas, &fa) } return fas, rows.Err() } func (s *DefaultForumActionStore) GetNewTopicActions(fid int) (fas []*ForumAction, e error) { rows, e := s.getNewTopicActions.Query(fid) if e != nil { return nil, e } defer rows.Close() var str string for rows.Next() { fa := ForumAction{RunOnTopicCreation: true} if e := rows.Scan(&fa.ID, &fa.Forum, &fa.RunDaysAfterTopicCreation, &fa.RunDaysAfterTopicLastReply, &str, &fa.Extra); e != nil { return nil, e } fa.Action = ConvStringToAct(str) fas = append(fas, &fa) } return fas, rows.Err() } func (s *DefaultForumActionStore) Add(fa *ForumAction) (int, error) { res, e := s.add.Exec(fa.Forum, fa.RunOnTopicCreation, fa.RunDaysAfterTopicCreation, fa.RunDaysAfterTopicLastReply, ConvActToString(fa.Action), fa.Extra) if e != nil { return 0, e } lastID, e := res.LastInsertId() return int(lastID), e } func (s *DefaultForumActionStore) Delete(id int) error { _, e := s.delete.Exec(id) return e } func (s *DefaultForumActionStore) Exists(id int) bool { err := s.exists.QueryRow(id).Scan(&id) if err != nil && err != ErrNoRows { LogError(err) } return err != ErrNoRows } func (s *DefaultForumActionStore) Count() (count int) { err := s.count.QueryRow().Scan(&count) if err != nil { LogError(err) } return count } func (s *DefaultForumActionStore) CountInForum(fid int) (count int) { return Countf(s.countInForum, fid) } /*type ForumActionRunnable struct { ID int ActionID int TargetID int TargetType int // 0 = topic RunAfter int //unixtime } type ForumActionRunnableStoreInt interface { GetAfterTime(unix int) ([]*ForumActionRunnable, error) GetInForum(fid int) ([]*ForumActionRunnable, error) Delete(faid int) error DeleteInForum(fid int) error DeleteByActionID(faid int) error Count() int CountInForum(fid int) int } type DefaultForumActionRunnableStore struct { delete *sql.Stmt deleteInForum *sql.Stmt count *sql.Stmt countInForum *sql.Stmt } func NewDefaultForumActionRunnableStore(acc *qgen.Accumulator) (*DefaultForumActionRunnableStore, error) { fa := "forums_actions" return &DefaultForumActionRunnableStore{ delete: acc.Delete(fa).Where("faid=?").Prepare(), deleteInForum: acc.Delete(fa).Where("fid=?").Prepare(), count: acc.Count(fa).Prepare(), countInForum: acc.Count(fa).Where("faid=?").Prepare(), }, acc.FirstError() } func (s *DefaultForumActionRunnableStore) Delete(id int) error { _, e := s.delete.Exec(id) return e } func (s *DefaultForumActionRunnableStore) DeleteInForum(fid int) error { _, e := s.deleteInForum.Exec(id) return e } func (s *DefaultForumActionRunnableStore) Count() (count int) { err := s.count.QueryRow().Scan(&count) if err != nil { LogError(err) } return count } func (s *DefaultForumActionRunnableStore) CountInForum(fid int) (count int) { return Countf(s.countInForum, fid) } */ ================================================ FILE: common/forum_perms.go ================================================ package common import ( "database/sql" "encoding/json" "github.com/Azareal/Gosora/query_gen" ) // ? - Can we avoid duplicating the items in this list in a bunch of places? var LocalPermList = []string{ "ViewTopic", "LikeItem", "CreateTopic", "EditTopic", "DeleteTopic", "CreateReply", "EditReply", "DeleteReply", "PinTopic", "CloseTopic", "MoveTopic", } // TODO: Rename this to ForumPermSet? /* Inherit from group permissions for ones we don't have */ type ForumPerms struct { ViewTopic bool //ViewOwnTopic bool LikeItem bool CreateTopic bool EditTopic bool DeleteTopic bool CreateReply bool //CreateReplyToOwn bool EditReply bool //EditOwnReply bool DeleteReply bool PinTopic bool CloseTopic bool //CloseOwnTopic bool MoveTopic bool Overrides bool ExtData map[string]bool } func PresetToPermmap(preset string) (out map[string]*ForumPerms) { out = make(map[string]*ForumPerms) switch preset { case "all": out["guests"] = ReadForumPerms() out["members"] = ReadWriteForumPerms() out["staff"] = AllForumPerms() out["admins"] = AllForumPerms() case "announce": out["guests"] = ReadForumPerms() out["members"] = ReadReplyForumPerms() out["staff"] = AllForumPerms() out["admins"] = AllForumPerms() case "members": out["guests"] = BlankForumPerms() out["members"] = ReadWriteForumPerms() out["staff"] = AllForumPerms() out["admins"] = AllForumPerms() case "staff": out["guests"] = BlankForumPerms() out["members"] = BlankForumPerms() out["staff"] = ReadWriteForumPerms() out["admins"] = AllForumPerms() case "admins": out["guests"] = BlankForumPerms() out["members"] = BlankForumPerms() out["staff"] = BlankForumPerms() out["admins"] = AllForumPerms() case "archive": out["guests"] = ReadForumPerms() out["members"] = ReadForumPerms() out["staff"] = ReadForumPerms() out["admins"] = ReadForumPerms() //CurateForumPerms. Delete / Edit but no create? default: out["guests"] = BlankForumPerms() out["members"] = BlankForumPerms() out["staff"] = BlankForumPerms() out["admins"] = BlankForumPerms() } return out } func PermmapToQuery(permmap map[string]*ForumPerms, fid int) error { tx, err := qgen.Builder.Begin() if err != nil { return err } defer tx.Rollback() deleteForumPermsByForumTx, err := qgen.Builder.SimpleDeleteTx(tx, "forums_permissions", "fid = ?") if err != nil { return err } _, err = deleteForumPermsByForumTx.Exec(fid) if err != nil { return err } perms, err := json.Marshal(permmap["admins"]) if err != nil { return err } addForumPermsToForumAdminsTx, err := qgen.Builder.SimpleInsertSelectTx(tx, qgen.DBInsert{"forums_permissions", "gid,fid,preset,permissions", ""}, qgen.DBSelect{"users_groups", "gid,?,'',?", "is_admin = 1", "", ""}, ) if err != nil { return err } _, err = addForumPermsToForumAdminsTx.Exec(fid, perms) if err != nil { return err } perms, err = json.Marshal(permmap["staff"]) if err != nil { return err } addForumPermsToForumStaffTx, err := qgen.Builder.SimpleInsertSelectTx(tx, qgen.DBInsert{"forums_permissions", "gid,fid,preset,permissions", ""}, qgen.DBSelect{"users_groups", "gid,?,'',?", "is_admin = 0 AND is_mod = 1", "", ""}, ) if err != nil { return err } _, err = addForumPermsToForumStaffTx.Exec(fid, perms) if err != nil { return err } perms, err = json.Marshal(permmap["members"]) if err != nil { return err } addForumPermsToForumMembersTx, err := qgen.Builder.SimpleInsertSelectTx(tx, qgen.DBInsert{"forums_permissions", "gid,fid,preset,permissions", ""}, qgen.DBSelect{"users_groups", "gid,?,'',?", "is_admin = 0 AND is_mod = 0 AND is_banned = 0", "", ""}, ) if err != nil { return err } _, err = addForumPermsToForumMembersTx.Exec(fid, perms) if err != nil { return err } // TODO: The group ID is probably a variable somewhere. Find it and use it. // Group 5 is the Awaiting Activation group err = ReplaceForumPermsForGroupTx(tx, 5, map[int]string{fid: ""}, map[int]*ForumPerms{fid: permmap["guests"]}) if err != nil { return err } // TODO: Consult a config setting instead of GuestUser? err = ReplaceForumPermsForGroupTx(tx, GuestUser.Group, map[int]string{fid: ""}, map[int]*ForumPerms{fid: permmap["guests"]}) if err != nil { return err } err = tx.Commit() if err != nil { return err } return FPStore.Reload(fid) //return TopicList.RebuildPermTree() } // TODO: FPStore.Reload? func ReplaceForumPermsForGroup(gid int, presetSet map[int]string, permSets map[int]*ForumPerms) error { tx, err := qgen.Builder.Begin() if err != nil { return err } defer tx.Rollback() err = ReplaceForumPermsForGroupTx(tx, gid, presetSet, permSets) if err != nil { return err } return tx.Commit() //return TopicList.RebuildPermTree() } func ReplaceForumPermsForGroupTx(tx *sql.Tx, gid int, presetSets map[int]string, permSets map[int]*ForumPerms) error { deleteForumPermsForGroupTx, err := qgen.Builder.SimpleDeleteTx(tx, "forums_permissions", "gid = ? AND fid = ?") if err != nil { return err } addForumPermsToGroupTx, err := qgen.Builder.SimpleInsertTx(tx, "forums_permissions", "gid,fid,preset,permissions", "?,?,?,?") if err != nil { return err } for fid, permSet := range permSets { permstr, err := json.Marshal(permSet) if err != nil { return err } _, err = deleteForumPermsForGroupTx.Exec(gid, fid) if err != nil { return err } _, err = addForumPermsToGroupTx.Exec(gid, fid, presetSets[fid], string(permstr)) if err != nil { return err } } return nil } // TODO: Refactor this and write tests for it // TODO: We really need to improve the thread safety of this func ForumPermsToGroupForumPreset(fp *ForumPerms) string { if !fp.Overrides { return "default" } if !fp.ViewTopic { return "no_access" } canPost := (fp.LikeItem && fp.CreateTopic && fp.CreateReply) canModerate := (canPost && fp.EditTopic && fp.DeleteTopic && fp.EditReply && fp.DeleteReply && fp.PinTopic && fp.CloseTopic && fp.MoveTopic) if canModerate { return "can_moderate" } if fp.EditTopic || fp.DeleteTopic || fp.EditReply || fp.DeleteReply || fp.PinTopic || fp.CloseTopic || fp.MoveTopic { //if !canPost { return "custom" //} //return "quasi_mod" } if canPost { return "can_post" } if fp.ViewTopic && !fp.LikeItem && !fp.CreateTopic && !fp.CreateReply { return "read_only" } return "custom" } func GroupForumPresetToForumPerms(preset string) (fperms *ForumPerms, changed bool) { switch preset { case "read_only": return ReadForumPerms(), true case "can_post": return ReadWriteForumPerms(), true case "can_moderate": return AllForumPerms(), true case "no_access": return &ForumPerms{Overrides: true, ExtData: make(map[string]bool)}, true case "default": return BlankForumPerms(), true } return fperms, false } func BlankForumPerms() *ForumPerms { return &ForumPerms{ViewTopic: false} } func ReadWriteForumPerms() *ForumPerms { return &ForumPerms{ ViewTopic: true, LikeItem: true, CreateTopic: true, CreateReply: true, Overrides: true, ExtData: make(map[string]bool), } } func ReadReplyForumPerms() *ForumPerms { return &ForumPerms{ ViewTopic: true, LikeItem: true, CreateReply: true, Overrides: true, ExtData: make(map[string]bool), } } func ReadForumPerms() *ForumPerms { return &ForumPerms{ ViewTopic: true, Overrides: true, ExtData: make(map[string]bool), } } // AllForumPerms is a set of forum local permissions with everything set to true func AllForumPerms() *ForumPerms { return &ForumPerms{ ViewTopic: true, LikeItem: true, CreateTopic: true, EditTopic: true, DeleteTopic: true, CreateReply: true, EditReply: true, DeleteReply: true, PinTopic: true, CloseTopic: true, MoveTopic: true, Overrides: true, ExtData: make(map[string]bool), } } ================================================ FILE: common/forum_perms_store.go ================================================ package common import ( "database/sql" "encoding/json" "sync" qgen "github.com/Azareal/Gosora/query_gen" ) var FPStore ForumPermsStore type ForumPermsStore interface { Init() error GetAllMap() (bigMap map[int]map[int]*ForumPerms) Get(fid, gid int) (fp *ForumPerms, err error) GetCopy(fid, gid int) (fp ForumPerms, err error) ReloadAll() error Reload(id int) error } type ForumPermsCache interface { } type MemoryForumPermsStore struct { getByForum *sql.Stmt getByForumGroup *sql.Stmt evenForums map[int]map[int]*ForumPerms oddForums map[int]map[int]*ForumPerms // [fid][gid]*ForumPerms evenLock sync.RWMutex oddLock sync.RWMutex } func NewMemoryForumPermsStore() (*MemoryForumPermsStore, error) { acc := qgen.NewAcc() fp := "forums_permissions" return &MemoryForumPermsStore{ getByForum: acc.Select(fp).Columns("gid,permissions").Where("fid=?").Orderby("gid ASC").Prepare(), getByForumGroup: acc.Select(fp).Columns("permissions").Where("fid=? AND gid=?").Prepare(), evenForums: make(map[int]map[int]*ForumPerms), oddForums: make(map[int]map[int]*ForumPerms), }, acc.FirstError() } func (s *MemoryForumPermsStore) Init() error { DebugLog("Initialising the forum perms store") return s.ReloadAll() } // TODO: Optimise this? func (s *MemoryForumPermsStore) ReloadAll() error { DebugLog("Reloading the forum perms") fids, e := Forums.GetAllIDs() if e != nil { return e } for _, fid := range fids { if e := s.reload(fid); e != nil { return e } } if e := s.recalcCanSeeAll(); e != nil { return e } TopicListThaw.Thaw() return nil } func (s *MemoryForumPermsStore) parseForumPerm(perms []byte) (pperms *ForumPerms, e error) { DebugDetail("perms: ", string(perms)) pperms = BlankForumPerms() e = json.Unmarshal(perms, &pperms) pperms.ExtData = make(map[string]bool) pperms.Overrides = true return pperms, e } func (s *MemoryForumPermsStore) Reload(fid int) error { e := s.reload(fid) if e != nil { return e } if e = s.recalcCanSeeAll(); e != nil { return e } TopicListThaw.Thaw() return nil } // TODO: Need a more thread-safe way of doing this. Possibly with sync.Map? func (s *MemoryForumPermsStore) reload(fid int) error { DebugLogf("Reloading the forum permissions for forum #%d", fid) rows, err := s.getByForum.Query(fid) if err != nil { return err } defer rows.Close() forumPerms := make(map[int]*ForumPerms) for rows.Next() { var gid int var perms []byte err := rows.Scan(&gid, &perms) if err != nil { return err } DebugLog("gid:", gid) DebugLogf("perms: %+v\n", perms) pperms, err := s.parseForumPerm(perms) if err != nil { return err } DebugLogf("pperms: %+v\n", pperms) forumPerms[gid] = pperms } DebugLogf("forumPerms: %+v\n", forumPerms) if fid%2 == 0 { s.evenLock.Lock() s.evenForums[fid] = forumPerms s.evenLock.Unlock() } else { s.oddLock.Lock() s.oddForums[fid] = forumPerms s.oddLock.Unlock() } return nil } func (s *MemoryForumPermsStore) recalcCanSeeAll() error { groups, err := Groups.GetAll() if err != nil { return err } fids, err := Forums.GetAllIDs() if err != nil { return err } gc, ok := Groups.(GroupCache) if !ok { TopicListThaw.Thaw() return nil } // A separate loop to avoid contending on the odd-even locks as much fForumPerms := make(map[int]map[int]*ForumPerms) for _, fid := range fids { var forumPerms map[int]*ForumPerms var ok bool if fid%2 == 0 { s.evenLock.RLock() forumPerms, ok = s.evenForums[fid] s.evenLock.RUnlock() } else { s.oddLock.RLock() forumPerms, ok = s.oddForums[fid] s.oddLock.RUnlock() } if ok { fForumPerms[fid] = forumPerms } } // TODO: Can we recalculate CanSee without calculating every other forum? for _, g := range groups { DebugLogf("Updating the forum permissions for Group #%d", g.ID) canSee := []int{} for _, fid := range fids { DebugDetailf("Forum #%+v\n", fid) forumPerms, ok := fForumPerms[fid] if !ok { continue } fp, ok := forumPerms[g.ID] if !ok { if g.Perms.ViewTopic { canSee = append(canSee, fid) } continue } if fp.Overrides { if fp.ViewTopic { canSee = append(canSee, fid) } } else if g.Perms.ViewTopic { canSee = append(canSee, fid) } //DebugDetail("g.ID: ", g.ID) DebugDetailf("forumPerm: %+v\n", fp) DebugDetail("canSee: ", canSee) } DebugDetailf("canSee (length %d): %+v \n", len(canSee), canSee) gc.SetCanSee(g.ID, canSee) } return nil } // ! Throughput on this might be bad due to the excessive locking func (s *MemoryForumPermsStore) GetAllMap() (bigMap map[int]map[int]*ForumPerms) { bigMap = make(map[int]map[int]*ForumPerms) s.evenLock.RLock() for fid, subMap := range s.evenForums { bigMap[fid] = subMap } s.evenLock.RUnlock() s.oddLock.RLock() for fid, subMap := range s.oddForums { bigMap[fid] = subMap } s.oddLock.RUnlock() return bigMap } // TODO: Add a hook here and have plugin_guilds use it // TODO: Check if the forum exists? // TODO: Fix the races // TODO: Return BlankForumPerms() when the forum permission set doesn't exist? func (s *MemoryForumPermsStore) Get(fid, gid int) (fp *ForumPerms, err error) { var fmap map[int]*ForumPerms var ok bool if fid%2 == 0 { s.evenLock.RLock() fmap, ok = s.evenForums[fid] s.evenLock.RUnlock() } else { s.oddLock.RLock() fmap, ok = s.oddForums[fid] s.oddLock.RUnlock() } if !ok { return fp, ErrNoRows } fp, ok = fmap[gid] if !ok { return fp, ErrNoRows } return fp, nil } // TODO: Check if the forum exists? // TODO: Fix the races func (s *MemoryForumPermsStore) GetCopy(fid, gid int) (fp ForumPerms, e error) { fPermsPtr, e := s.Get(fid, gid) if e != nil { return fp, e } return *fPermsPtr, nil } ================================================ FILE: common/forum_store.go ================================================ /* * * Gosora Forum Store * Copyright Azareal 2017 - 2020 * */ package common import ( "database/sql" "errors" "log" //"fmt" "sort" "sync" "sync/atomic" qgen "github.com/Azareal/Gosora/query_gen" ) var forumCreateMutex sync.Mutex var forumPerms map[int]map[int]*ForumPerms // [gid][fid]*ForumPerms // TODO: Add an abstraction around this and make it more thread-safe var Forums ForumStore var ErrBlankName = errors.New("The name must not be blank") var ErrNoDeleteReports = errors.New("You cannot delete the Reports forum") // ForumStore is an interface for accessing the forums and the metadata stored on them type ForumStore interface { LoadForums() error Each(h func(*Forum) error) error DirtyGet(id int) *Forum Get(id int) (*Forum, error) BypassGet(id int) (*Forum, error) BulkGetCopy(ids []int) (forums []Forum, err error) Reload(id int) error // ? - Should we move this to ForumCache? It might require us to do some unnecessary casting though //Update(Forum) error Delete(id int) error AddTopic(tid, uid, fid int) error RemoveTopic(fid int) error RemoveTopics(fid, count int) error UpdateLastTopic(tid, uid, fid int) error Exists(id int) bool GetAll() ([]*Forum, error) GetAllIDs() ([]int, error) GetAllVisible() ([]*Forum, error) GetAllVisibleIDs() ([]int, error) //GetChildren(parentID int, parentType string) ([]*Forum,error) //GetFirstChild(parentID int, parentType string) (*Forum,error) Create(name, desc string, active bool, preset string) (int, error) UpdateOrder(updateMap map[int]int) error Count() int } type ForumCache interface { CacheGet(id int) (*Forum, error) CacheSet(f *Forum) error CacheDelete(id int) Length() int } // MemoryForumStore is a struct which holds an arbitrary number of forums in memory, usually all of them, although we might introduce functionality to hold a smaller subset in memory for sites with an extremely large number of forums type MemoryForumStore struct { forums sync.Map // map[int]*Forum forumView atomic.Value // []*Forum get *sql.Stmt getAll *sql.Stmt delete *sql.Stmt create *sql.Stmt count *sql.Stmt updateCache *sql.Stmt addTopics *sql.Stmt removeTopics *sql.Stmt lastTopic *sql.Stmt updateOrder *sql.Stmt } // NewMemoryForumStore gives you a new instance of MemoryForumStore func NewMemoryForumStore() (*MemoryForumStore, error) { acc := qgen.NewAcc() f := "forums" set := func(s string) *sql.Stmt { return acc.Update(f).Set(s).Where("fid=?").Prepare() } // TODO: Do a proper delete return &MemoryForumStore{ get: acc.Select(f).Columns("name, desc, tmpl, active, order, preset, parentID, parentType, topicCount, lastTopicID, lastReplyerID").Where("fid=?").Prepare(), getAll: acc.Select(f).Columns("fid, name, desc, tmpl, active, order, preset, parentID, parentType, topicCount, lastTopicID, lastReplyerID").Orderby("order ASC, fid ASC").Prepare(), delete: set("name='',active=0"), create: acc.Insert(f).Columns("name,desc,tmpl,active,preset").Fields("?,?,'',?,?").Prepare(), count: acc.Count(f).Where("name != ''").Prepare(), updateCache: set("lastTopicID=?,lastReplyerID=?"), addTopics: set("topicCount=topicCount+?"), removeTopics: set("topicCount=topicCount-?"), lastTopic: acc.Select("topics").Columns("tid").Where("parentID=?").Orderby("lastReplyAt DESC,createdAt DESC").Limit("1").Prepare(), updateOrder: set("order=?"), }, acc.FirstError() } // TODO: Rename to ReloadAll? // TODO: Add support for subforums func (s *MemoryForumStore) LoadForums() error { var forumView []*Forum addForum := func(f *Forum) { s.forums.Store(f.ID, f) if f.Active && f.Name != "" && f.ParentType == "" { forumView = append(forumView, f) } } rows, err := s.getAll.Query() if err != nil { return err } defer rows.Close() i := 0 for ; rows.Next(); i++ { f := &Forum{ID: 0, Active: true, Preset: "all"} err = rows.Scan(&f.ID, &f.Name, &f.Desc, &f.Tmpl, &f.Active, &f.Order, &f.Preset, &f.ParentID, &f.ParentType, &f.TopicCount, &f.LastTopicID, &f.LastReplyerID) if err != nil { return err } if f.Name == "" { DebugLog("Adding a placeholder forum") } else { log.Printf("Adding the '%s' forum", f.Name) } f.Link = BuildForumURL(NameToSlug(f.Name), f.ID) f.LastTopic = Topics.DirtyGet(f.LastTopicID) f.LastReplyer = Users.DirtyGet(f.LastReplyerID) // TODO: Create a specialised function with a bit less overhead for getting the last page for a post count _, _, lastPage := PageOffset(f.LastTopic.PostCount, 1, Config.ItemsPerPage) f.LastPage = lastPage addForum(f) } s.forumView.Store(forumView) TopicListThaw.Thaw() return rows.Err() } // TODO: Hide social groups too // ? - Will this be hit a lot by plugin_guilds? func (s *MemoryForumStore) rebuildView() { var forumView []*Forum s.forums.Range(func(_, val interface{}) bool { f := val.(*Forum) // ? - ParentType blank means that it doesn't have a parent if f.Active && f.Name != "" && f.ParentType == "" { forumView = append(forumView, f) } return true }) sort.Sort(SortForum(forumView)) s.forumView.Store(forumView) TopicListThaw.Thaw() } func (s *MemoryForumStore) Each(h func(*Forum) error) (err error) { s.forums.Range(func(_, val interface{}) bool { err = h(val.(*Forum)) if err != nil { return false } return true }) return err } func (s *MemoryForumStore) DirtyGet(id int) *Forum { fint, ok := s.forums.Load(id) if !ok || fint.(*Forum).Name == "" { return &Forum{ID: -1, Name: ""} } return fint.(*Forum) } func (s *MemoryForumStore) CacheGet(id int) (*Forum, error) { fint, ok := s.forums.Load(id) if !ok || fint.(*Forum).Name == "" { return nil, ErrNoRows } return fint.(*Forum), nil } func (s *MemoryForumStore) Get(id int) (*Forum, error) { fint, ok := s.forums.Load(id) if ok { forum := fint.(*Forum) if forum.Name == "" { return nil, ErrNoRows } return forum, nil } forum, err := s.BypassGet(id) if err != nil { return nil, err } s.CacheSet(forum) return forum, err } func (s *MemoryForumStore) BypassGet(id int) (*Forum, error) { f := &Forum{ID: id} err := s.get.QueryRow(id).Scan(&f.Name, &f.Desc, &f.Tmpl, &f.Active, &f.Order, &f.Preset, &f.ParentID, &f.ParentType, &f.TopicCount, &f.LastTopicID, &f.LastReplyerID) if err != nil { return nil, err } if f.Name == "" { return nil, ErrNoRows } f.Link = BuildForumURL(NameToSlug(f.Name), f.ID) f.LastTopic = Topics.DirtyGet(f.LastTopicID) f.LastReplyer = Users.DirtyGet(f.LastReplyerID) // TODO: Create a specialised function with a bit less overhead for getting the last page for a post count _, _, lastPage := PageOffset(f.LastTopic.PostCount, 1, Config.ItemsPerPage) f.LastPage = lastPage //TopicListThaw.Thaw() return f, err } // TODO: Optimise this func (s *MemoryForumStore) BulkGetCopy(ids []int) (forums []Forum, err error) { forums = make([]Forum, len(ids)) for i, id := range ids { f, err := s.Get(id) if err != nil { return nil, err } forums[i] = f.Copy() } return forums, nil } func (s *MemoryForumStore) Reload(id int) error { forum, err := s.BypassGet(id) if err != nil { return err } s.CacheSet(forum) return nil } func (s *MemoryForumStore) CacheSet(f *Forum) error { s.forums.Store(f.ID, f) s.rebuildView() return nil } // ! Has a randomised order func (s *MemoryForumStore) GetAll() (forumView []*Forum, err error) { s.forums.Range(func(_, val interface{}) bool { forumView = append(forumView, val.(*Forum)) return true }) sort.Sort(SortForum(forumView)) return forumView, nil } // ? - Can we optimise the sorting? func (s *MemoryForumStore) GetAllIDs() (ids []int, err error) { s.forums.Range(func(_, val interface{}) bool { ids = append(ids, val.(*Forum).ID) return true }) sort.Ints(ids) return ids, nil } func (s *MemoryForumStore) GetAllVisible() (forumView []*Forum, err error) { forumView = s.forumView.Load().([]*Forum) return forumView, nil } func (s *MemoryForumStore) GetAllVisibleIDs() ([]int, error) { forumView := s.forumView.Load().([]*Forum) ids := make([]int, len(forumView)) for i := 0; i < len(forumView); i++ { ids[i] = forumView[i].ID } return ids, nil } // TODO: Implement sub-forums. /*func (s *MemoryForumStore) GetChildren(parentID int, parentType string) ([]*Forum,error) { return nil, nil } func (s *MemoryForumStore) GetFirstChild(parentID int, parentType string) (*Forum,error) { return nil, nil }*/ // TODO: Add a query for this rather than hitting cache func (s *MemoryForumStore) Exists(id int) bool { forum, ok := s.forums.Load(id) if !ok { return false } return forum.(*Forum).Name != "" } // TODO: Batch deletions with name blanking? Is this necessary? func (s *MemoryForumStore) CacheDelete(id int) { s.forums.Delete(id) s.rebuildView() } // TODO: Add a hook to allow plugin_guilds to detect when one of it's forums has just been deleted? func (s *MemoryForumStore) Delete(id int) error { if id == ReportForumID { return ErrNoDeleteReports } _, err := s.delete.Exec(id) s.CacheDelete(id) return err } func (s *MemoryForumStore) AddTopic(tid, uid, fid int) error { _, err := s.updateCache.Exec(tid, uid, fid) if err != nil { return err } _, err = s.addTopics.Exec(1, fid) if err != nil { return err } // TODO: Bypass the database and update this with a lock or an unsafe atomic swap return s.Reload(fid) } func (s *MemoryForumStore) RefreshTopic(fid int) (err error) { var tid int err = s.lastTopic.QueryRow(fid).Scan(&tid) if err == sql.ErrNoRows { f, err := s.CacheGet(fid) if err != nil || f.LastTopicID != 0 { _, err = s.updateCache.Exec(0, 0, fid) if err != nil { return err } s.Reload(fid) } return nil } if err != nil { return err } topic, err := Topics.Get(tid) if err != nil { return err } _, err = s.updateCache.Exec(tid, topic.CreatedBy, fid) if err != nil { return err } // TODO: Bypass the database and update this with a lock or an unsafe atomic swap s.Reload(fid) return nil } // TODO: Make this update more atomic func (s *MemoryForumStore) RemoveTopic(fid int) error { _, err := s.removeTopics.Exec(1, fid) if err != nil { return err } return s.RefreshTopic(fid) } func (s *MemoryForumStore) RemoveTopics(fid, count int) error { _, err := s.removeTopics.Exec(count, fid) if err != nil { return err } return s.RefreshTopic(fid) } // DEPRECATED. forum.Update() will be the way to do this in the future, once it's completed // TODO: Have a pointer to the last topic rather than storing it on the forum itself func (s *MemoryForumStore) UpdateLastTopic(tid, uid, fid int) error { _, err := s.updateCache.Exec(tid, uid, fid) if err != nil { return err } // TODO: Bypass the database and update this with a lock or an unsafe atomic swap return s.Reload(fid) } func (s *MemoryForumStore) Create(name, desc string, active bool, preset string) (int, error) { if name == "" { return 0, ErrBlankName } forumCreateMutex.Lock() defer forumCreateMutex.Unlock() res, err := s.create.Exec(name, desc, active, preset) if err != nil { return 0, err } fid64, err := res.LastInsertId() if err != nil { return 0, err } fid := int(fid64) err = s.Reload(fid) if err != nil { return 0, err } PermmapToQuery(PresetToPermmap(preset), fid) return fid, nil } // TODO: Make this atomic, maybe with a transaction? func (s *MemoryForumStore) UpdateOrder(updateMap map[int]int) error { for fid, order := range updateMap { _, err := s.updateOrder.Exec(order, fid) if err != nil { return err } } return s.LoadForums() } // ! Might be slightly inaccurate, if the sync.Map is constantly shifting and churning, but it'll stabilise eventually. Also, slow. Don't use this on every request x.x // Length returns the number of forums in the memory cache func (s *MemoryForumStore) Length() (len int) { s.forums.Range(func(_, _ interface{}) bool { len++ return true }) return len } // TODO: Get the total count of forums in the forum store rather than doing a heavy query for this? // Count returns the total number of forums func (s *MemoryForumStore) Count() (count int) { err := s.count.QueryRow().Scan(&count) if err != nil { LogError(err) } return count } // TODO: Work on SqlForumStore // TODO: Work on the NullForumStore ================================================ FILE: common/gauth/authenticator.go ================================================ // Google Authenticator 2FA // Borrowed from https://github.com/tilaklodha/google-authenticator, as we can't import it as a library as it's in package main package gauth import ( "bytes" "crypto/hmac" "crypto/sha1" "encoding/base32" "encoding/binary" "strconv" "strings" "time" ) // Append extra 0s if the length of otp is less than 6 // If otp is "1234", it will return it as "001234" func prefix0(otp string) string { if len(otp) == 6 { return otp } for i := (6 - len(otp)); i > 0; i-- { otp = "0" + otp } return otp } func GetHOTPToken(secret string, interval int64) (string, error) { secret = strings.Replace(secret, " ", "", -1) // Converts secret to base32 Encoding. Base32 encoding desires a 32-character subset of the twenty-six letters A–Z and ten digits 0–9 key, err := base32.StdEncoding.DecodeString(strings.ToUpper(secret)) if err != nil { return "", err } bs := make([]byte, 8) binary.BigEndian.PutUint64(bs, uint64(interval)) // Signing the value using HMAC-SHA1 Algorithm hash := hmac.New(sha1.New, key) hash.Write(bs) h := hash.Sum(nil) // We're going to use a subset of the generated hash. // Using the last nibble (half-byte) to choose the index to start from. // This number is always appropriate as it's maximum decimal 15, the hash will have the maximum index 19 (20 bytes of SHA1) and we need 4 bytes. o := (h[19] & 15) var header uint32 // Get 32 bit chunk from hash starting at the o r := bytes.NewReader(h[o : o+4]) err = binary.Read(r, binary.BigEndian, &header) if err != nil { return "", err } // Ignore most significant bits as per RFC 4226. // Takes division from one million to generate a remainder less than < 7 digits h12 := (int(header) & 0x7fffffff) % 1000000 return prefix0(strconv.Itoa(int(h12))), nil } func GetTOTPToken(secret string) (string, error) { // The TOTP token is just a HOTP token seeded with every 30 seconds. interval := time.Now().Unix() / 30 return GetHOTPToken(secret, interval) } ================================================ FILE: common/group.go ================================================ package common import ( "database/sql" "encoding/json" qgen "github.com/Azareal/Gosora/query_gen" ) var blankGroup = Group{ID: 0, Name: ""} type GroupAdmin struct { ID int Name string Rank string RankClass string CanEdit bool CanDelete bool } // ! Fix the data races in the fperms type Group struct { ID int Name string IsMod bool IsAdmin bool IsBanned bool Tag string Perms Perms PermissionsText []byte PluginPerms map[string]bool // Custom permissions defined by plugins. What if two plugins declare the same permission, but they handle them in incompatible ways? Very unlikely, we probably don't need to worry about this, the plugin authors should be aware of each other to some extent PluginPermsText []byte CanSee []int // The IDs of the forums this group can see UserCount int // ! Might be temporary as I might want to lean on the database instead for this } type GroupStmts struct { updateGroup *sql.Stmt updateGroupRank *sql.Stmt updateGroupPerms *sql.Stmt } var groupStmts GroupStmts func init() { DbInits.Add(func(acc *qgen.Accumulator) error { set := func(s string) *sql.Stmt { return acc.Update("users_groups").Set(s).Where("gid=?").Prepare() } groupStmts = GroupStmts{ updateGroup: set("name=?,tag=?"), updateGroupRank: set("is_admin=?,is_mod=?,is_banned=?"), updateGroupPerms: set("permissions=?"), } return acc.FirstError() }) } func (g *Group) ChangeRank(isAdmin, isMod, isBanned bool) (err error) { _, err = groupStmts.updateGroupRank.Exec(isAdmin, isMod, isBanned, g.ID) if err != nil { return err } _ = Groups.Reload(g.ID) return nil } func (g *Group) Update(name, tag string) (err error) { _, err = groupStmts.updateGroup.Exec(name, tag, g.ID) if err != nil { return err } _ = Groups.Reload(g.ID) return nil } // Please don't pass arbitrary inputs to this method func (g *Group) UpdatePerms(perms map[string]bool) (err error) { pjson, err := json.Marshal(perms) if err != nil { return err } _, err = groupStmts.updateGroupPerms.Exec(pjson, g.ID) if err != nil { return err } return Groups.Reload(g.ID) } // Copy gives you a non-pointer concurrency safe copy of the group func (g *Group) Copy() Group { return *g } func (g *Group) CopyPtr() (co *Group) { co = new(Group) *co = *g return co } // TODO: Replace this sorting mechanism with something a lot more efficient // ? - Use sort.Slice instead? type SortGroup []*Group func (sg SortGroup) Len() int { return len(sg) } func (sg SortGroup) Swap(i, j int) { sg[i], sg[j] = sg[j], sg[i] } func (sg SortGroup) Less(i, j int) bool { return sg[i].ID < sg[j].ID } ================================================ FILE: common/group_store.go ================================================ /* Under Heavy Construction */ package common import ( "database/sql" "encoding/json" "errors" "log" "sort" "strconv" "sync" qgen "github.com/Azareal/Gosora/query_gen" ) var Groups GroupStore // ? - We could fallback onto the database when an item can't be found in the cache? type GroupStore interface { LoadGroups() error DirtyGet(id int) *Group Get(id int) (*Group, error) GetCopy(id int) (Group, error) Exists(id int) bool Create(name, tag string, isAdmin, isMod, isBanned bool) (id int, err error) GetAll() ([]*Group, error) GetRange(lower, higher int) ([]*Group, error) Reload(id int) error // ? - Should we move this to GroupCache? It might require us to do some unnecessary casting though Count() int } type GroupCache interface { CacheSet(g *Group) error SetCanSee(gid int, canSee []int) error CacheAdd(g *Group) error Length() int } type MemoryGroupStore struct { groups map[int]*Group // TODO: Use a sync.Map instead of a map? groupCount int getAll *sql.Stmt get *sql.Stmt count *sql.Stmt userCount *sql.Stmt sync.RWMutex } func NewMemoryGroupStore() (*MemoryGroupStore, error) { acc := qgen.NewAcc() ug := "users_groups" return &MemoryGroupStore{ groups: make(map[int]*Group), groupCount: 0, getAll: acc.Select(ug).Columns("gid,name,permissions,plugin_perms,is_mod,is_admin,is_banned,tag").Prepare(), get: acc.Select(ug).Columns("name,permissions,plugin_perms,is_mod,is_admin,is_banned,tag").Where("gid=?").Prepare(), count: acc.Count(ug).Prepare(), userCount: acc.Count("users").Where("group=?").Prepare(), }, acc.FirstError() } // TODO: Move this query from the global stmt store into this store func (s *MemoryGroupStore) LoadGroups() error { s.Lock() defer s.Unlock() s.groups[0] = &Group{ID: 0, Name: "Unknown"} rows, err := s.getAll.Query() if err != nil { return err } defer rows.Close() i := 1 for ; rows.Next(); i++ { g := &Group{ID: 0} err := rows.Scan(&g.ID, &g.Name, &g.PermissionsText, &g.PluginPermsText, &g.IsMod, &g.IsAdmin, &g.IsBanned, &g.Tag) if err != nil { return err } err = s.initGroup(g) if err != nil { return err } s.groups[g.ID] = g } if err = rows.Err(); err != nil { return err } s.groupCount = i DebugLog("Binding the Not Loggedin Group") GuestPerms = s.dirtyGetUnsafe(6).Perms // ! Race? TopicListThaw.Thaw() return nil } // TODO: Hit the database when the item isn't in memory func (s *MemoryGroupStore) dirtyGetUnsafe(id int) *Group { group, ok := s.groups[id] if !ok { return &blankGroup } return group } // TODO: Hit the database when the item isn't in memory func (s *MemoryGroupStore) DirtyGet(id int) *Group { s.RLock() group, ok := s.groups[id] s.RUnlock() if !ok { return &blankGroup } return group } // TODO: Hit the database when the item isn't in memory func (s *MemoryGroupStore) Get(id int) (*Group, error) { s.RLock() group, ok := s.groups[id] s.RUnlock() if !ok { return nil, ErrNoRows } return group, nil } // TODO: Hit the database when the item isn't in memory func (s *MemoryGroupStore) GetCopy(id int) (Group, error) { s.RLock() group, ok := s.groups[id] s.RUnlock() if !ok { return blankGroup, ErrNoRows } return *group, nil } func (s *MemoryGroupStore) Reload(id int) error { // TODO: Reload this data too g, e := s.Get(id) if e != nil { LogError(errors.New("can't get cansee data for group #" + strconv.Itoa(id))) return nil } canSee := g.CanSee g = &Group{ID: id, CanSee: canSee} e = s.get.QueryRow(id).Scan(&g.Name, &g.PermissionsText, &g.PluginPermsText, &g.IsMod, &g.IsAdmin, &g.IsBanned, &g.Tag) if e != nil { return e } if e = s.initGroup(g); e != nil { LogError(e) return nil } s.CacheSet(g) TopicListThaw.Thaw() return nil } func (s *MemoryGroupStore) initGroup(g *Group) error { e := json.Unmarshal(g.PermissionsText, &g.Perms) if e != nil { log.Printf("g: %+v\n", g) log.Print("bad group perms: ", g.PermissionsText) return e } DebugLogf(g.Name+": %+v\n", g.Perms) e = json.Unmarshal(g.PluginPermsText, &g.PluginPerms) if e != nil { log.Printf("g: %+v\n", g) log.Print("bad group plugin perms: ", g.PluginPermsText) return e } DebugLogf(g.Name+": %+v\n", g.PluginPerms) //group.Perms.ExtData = make(map[string]bool) // TODO: Can we optimise the bit where this cascades down to the user now? if g.IsAdmin || g.IsMod { g.IsBanned = false } e = s.userCount.QueryRow(g.ID).Scan(&g.UserCount) if e != sql.ErrNoRows { return e } return nil } func (s *MemoryGroupStore) SetCanSee(gid int, canSee []int) error { s.Lock() group, ok := s.groups[gid] if !ok { s.Unlock() return nil } ngroup := &Group{} *ngroup = *group ngroup.CanSee = canSee s.groups[group.ID] = ngroup s.Unlock() return nil } func (s *MemoryGroupStore) CacheSet(g *Group) error { s.Lock() s.groups[g.ID] = g s.Unlock() return nil } // TODO: Hit the database when the item isn't in memory func (s *MemoryGroupStore) Exists(id int) bool { s.RLock() group, ok := s.groups[id] s.RUnlock() return ok && group.Name != "" } // ? Allow two groups with the same name? // TODO: Refactor this func (s *MemoryGroupStore) Create(name, tag string, isAdmin, isMod, isBanned bool) (gid int, err error) { permstr := "{}" tx, err := qgen.Builder.Begin() if err != nil { return 0, err } defer tx.Rollback() insertTx, err := qgen.Builder.SimpleInsertTx(tx, "users_groups", "name,tag,is_admin,is_mod,is_banned,permissions,plugin_perms", "?,?,?,?,?,?,'{}'") if err != nil { return 0, err } res, err := insertTx.Exec(name, tag, isAdmin, isMod, isBanned, permstr) if err != nil { return 0, err } gid64, err := res.LastInsertId() if err != nil { return 0, err } gid = int(gid64) perms := BlankPerms blankIntList := []int{} pluginPerms := make(map[string]bool) pluginPermsBytes := []byte("{}") GetHookTable().Vhook("create_group_preappend", &pluginPerms, &pluginPermsBytes) // Generate the forum permissions based on the presets... forums, err := Forums.GetAll() if err != nil { return 0, err } presetSet := make(map[int]string) permSet := make(map[int]*ForumPerms) for _, f := range forums { var thePreset string switch { case isAdmin: thePreset = "admins" case isMod: thePreset = "staff" case isBanned: thePreset = "banned" default: thePreset = "members" } permmap := PresetToPermmap(f.Preset) permItem := permmap[thePreset] permItem.Overrides = true permSet[f.ID] = permItem presetSet[f.ID] = f.Preset } err = ReplaceForumPermsForGroupTx(tx, gid, presetSet, permSet) if err != nil { return 0, err } err = tx.Commit() if err != nil { return 0, err } // TODO: Can we optimise the bit where this cascades down to the user now? if isAdmin || isMod { isBanned = false } s.CacheAdd(&Group{gid, name, isMod, isAdmin, isBanned, tag, perms, []byte(permstr), pluginPerms, pluginPermsBytes, blankIntList, 0}) TopicListThaw.Thaw() return gid, FPStore.ReloadAll() //return gid, TopicList.RebuildPermTree() } func (s *MemoryGroupStore) CacheAdd(g *Group) error { s.Lock() s.groups[g.ID] = g s.groupCount++ s.Unlock() return nil } func (s *MemoryGroupStore) GetAll() (results []*Group, err error) { var i int s.RLock() results = make([]*Group, len(s.groups)) for _, group := range s.groups { results[i] = group i++ } s.RUnlock() sort.Sort(SortGroup(results)) return results, nil } func (s *MemoryGroupStore) GetAllMap() (map[int]*Group, error) { s.RLock() defer s.RUnlock() return s.groups, nil } // ? - Set the lower and higher numbers to 0 to remove the bounds // TODO: Might be a little slow right now, maybe we can cache the groups in a slice or break the map up into chunks func (s *MemoryGroupStore) GetRange(lower, higher int) (groups []*Group, err error) { if lower == 0 && higher == 0 { return s.GetAll() } // TODO: Simplify these four conditionals into two if lower == 0 { if higher < 0 { return nil, errors.New("higher may not be lower than 0") } } else if higher == 0 { if lower < 0 { return nil, errors.New("lower may not be lower than 0") } } s.RLock() for gid, group := range s.groups { if gid >= lower && (gid <= higher || higher == 0) { groups = append(groups, group) } } s.RUnlock() sort.Sort(SortGroup(groups)) return groups, nil } func (s *MemoryGroupStore) Length() int { s.RLock() defer s.RUnlock() return s.groupCount } func (s *MemoryGroupStore) Count() (count int) { err := s.count.QueryRow().Scan(&count) if err != nil { LogError(err) } return count } ================================================ FILE: common/ip_search.go ================================================ package common import ( "database/sql" qgen "github.com/Azareal/Gosora/query_gen" ) var IPSearch IPSearcher type IPSearcher interface { Lookup(ip string) (uids []int, e error) } type DefaultIPSearcher struct { searchUsers *sql.Stmt searchTopics *sql.Stmt searchReplies *sql.Stmt searchUsersReplies *sql.Stmt } // NewDefaultIPSearcher gives you a new instance of DefaultIPSearcher func NewDefaultIPSearcher() (*DefaultIPSearcher, error) { acc := qgen.NewAcc() uu := "users" q := func(tbl string) *sql.Stmt { return acc.Select(uu).Columns("uid").InQ("uid", acc.Select(tbl).Columns("createdBy").Where("ip=?")).Prepare() } return &DefaultIPSearcher{ searchUsers: acc.Select(uu).Columns("uid").Where("last_ip=? OR last_ip LIKE CONCAT('%-',?)").Prepare(), searchTopics: q("topics"), searchReplies: q("replies"), searchUsersReplies: q("users_replies"), }, acc.FirstError() } func (s *DefaultIPSearcher) Lookup(ip string) (uids []int, e error) { var uid int reqUserList := make(map[int]bool) runQuery2 := func(rows *sql.Rows, e error) error { if e != nil { return e } defer rows.Close() for rows.Next() { if e := rows.Scan(&uid); e != nil { return e } reqUserList[uid] = true } return rows.Err() } runQuery := func(stmt *sql.Stmt) error { return runQuery2(stmt.Query(ip)) } e = runQuery2(s.searchUsers.Query(ip, ip)) if e != nil { return uids, e } e = runQuery(s.searchTopics) if e != nil { return uids, e } e = runQuery(s.searchReplies) if e != nil { return uids, e } e = runQuery(s.searchUsersReplies) if e != nil { return uids, e } // Convert the user ID map to a slice, then bulk load the users uids = make([]int, len(reqUserList)) var i int for userID := range reqUserList { uids[i] = userID i++ } return uids, nil } ================================================ FILE: common/likes.go ================================================ package common import ( "database/sql" qgen "github.com/Azareal/Gosora/query_gen" ) var Likes LikeStore type LikeStore interface { BulkExists(ids []int, sentBy int, targetType string) ([]int, error) BulkExistsFunc(ids []int, sentBy int, targetType string, f func(int) error) error Delete(targetID int, targetType string) error Count() (count int) } type DefaultLikeStore struct { count *sql.Stmt delete *sql.Stmt singleExists *sql.Stmt } func NewDefaultLikeStore(acc *qgen.Accumulator) (*DefaultLikeStore, error) { return &DefaultLikeStore{ count: acc.Count("likes").Prepare(), delete: acc.Delete("likes").Where("targetItem=? AND targetType=?").Prepare(), singleExists: acc.Select("likes").Columns("targetItem").Where("sentBy=? AND targetType=? AND targetItem=?").Prepare(), }, acc.FirstError() } // TODO: Write a test for this func (s *DefaultLikeStore) BulkExists(ids []int, sentBy int, targetType string) (eids []int, e error) { if len(ids) == 0 { return nil, nil } var rows *sql.Rows if len(ids) == 1 { rows, e = s.singleExists.Query(sentBy, targetType, ids[0]) } else { rows, e = qgen.NewAcc().Select("likes").Columns("targetItem").Where("sentBy=? AND targetType=?").In("targetItem", ids).Query(sentBy, targetType) } if e == sql.ErrNoRows { return nil, nil } else if e != nil { return nil, e } defer rows.Close() var id int for rows.Next() { if e := rows.Scan(&id); e != nil { return nil, e } eids = append(eids, id) } return eids, rows.Err() } // TODO: Write a test for this func (s *DefaultLikeStore) BulkExistsFunc(ids []int, sentBy int, targetType string, f func(id int) error) (e error) { if len(ids) == 0 { return nil } var rows *sql.Rows if len(ids) == 1 { rows, e = s.singleExists.Query(sentBy, targetType, ids[0]) } else { rows, e = qgen.NewAcc().Select("likes").Columns("targetItem").Where("sentBy=? AND targetType=?").In("targetItem", ids).Query(sentBy, targetType) } if e == sql.ErrNoRows { return nil } else if e != nil { return e } defer rows.Close() var id int for rows.Next() { if e := rows.Scan(&id); e != nil { return e } if e := f(id); e != nil { return e } } return rows.Err() } func (s *DefaultLikeStore) Delete(targetID int, targetType string) error { _, err := s.delete.Exec(targetID, targetType) return err } // TODO: Write a test for this // Count returns the total number of likes globally func (s *DefaultLikeStore) Count() (count int) { e := s.count.QueryRow().Scan(&count) if e != nil { LogError(e) } return count } ================================================ FILE: common/menu_item_store.go ================================================ package common import "sync" type DefaultMenuItemStore struct { items map[int]MenuItem lock sync.RWMutex } func NewDefaultMenuItemStore() *DefaultMenuItemStore { return &DefaultMenuItemStore{ items: make(map[int]MenuItem), } } func (s *DefaultMenuItemStore) Add(i MenuItem) { s.lock.Lock() defer s.lock.Unlock() s.items[i.ID] = i } func (s *DefaultMenuItemStore) Get(id int) (MenuItem, error) { s.lock.RLock() item, ok := s.items[id] s.lock.RUnlock() if ok { return item, nil } return item, ErrNoRows } ================================================ FILE: common/menu_store.go ================================================ package common import ( "database/sql" "strconv" "sync/atomic" qgen "github.com/Azareal/Gosora/query_gen" ) var Menus *DefaultMenuStore type DefaultMenuStore struct { menus map[int]*atomic.Value itemStore *DefaultMenuItemStore } func NewDefaultMenuStore() *DefaultMenuStore { return &DefaultMenuStore{ make(map[int]*atomic.Value), NewDefaultMenuItemStore(), } } // TODO: Add actual support for multiple menus func (s *DefaultMenuStore) GetAllMap() (out map[int]*MenuListHolder) { out = make(map[int]*MenuListHolder) for mid, atom := range s.menus { out[mid] = atom.Load().(*MenuListHolder) } return out } func (s *DefaultMenuStore) Get(mid int) (*MenuListHolder, error) { aStore, ok := s.menus[mid] if ok { return aStore.Load().(*MenuListHolder), nil } return nil, ErrNoRows } func (s *DefaultMenuStore) Items(mid int) (mlist MenuItemList, err error) { err = qgen.NewAcc().Select("menu_items").Columns("miid,name,htmlID,cssClass,position,path,aria,tooltip,order,tmplName,guestOnly,memberOnly,staffOnly,adminOnly").Where("mid=" + strconv.Itoa(mid)).Orderby("order ASC").Each(func(rows *sql.Rows) error { i := MenuItem{MenuID: mid} err := rows.Scan(&i.ID, &i.Name, &i.HTMLID, &i.CSSClass, &i.Position, &i.Path, &i.Aria, &i.Tooltip, &i.Order, &i.TmplName, &i.GuestOnly, &i.MemberOnly, &i.SuperModOnly, &i.AdminOnly) if err != nil { return err } s.itemStore.Add(i) mlist = append(mlist, i) return nil }) return mlist, err } func (s *DefaultMenuStore) Load(mid int) error { mlist, err := s.Items(mid) if err != nil { return err } hold := &MenuListHolder{mid, mlist, make(map[int]menuTmpl)} err = hold.Preparse() if err != nil { return err } aStore := &atomic.Value{} aStore.Store(hold) s.menus[mid] = aStore return nil } func (s *DefaultMenuStore) ItemStore() *DefaultMenuItemStore { return s.itemStore } ================================================ FILE: common/menus.go ================================================ package common import ( "bytes" "database/sql" "fmt" "io" "io/ioutil" "strconv" "strings" "github.com/Azareal/Gosora/common/phrases" tmpl "github.com/Azareal/Gosora/common/templates" qgen "github.com/Azareal/Gosora/query_gen" ) type MenuItemList []MenuItem type MenuListHolder struct { MenuID int List MenuItemList Variations map[int]menuTmpl // 0 = Guest Menu, 1 = Member Menu, 2 = Super Mod Menu, 3 = Admin Menu } type menuPath struct { Path string Index int } type menuTmpl struct { RenderBuffer [][]byte VariableIndices []int PathMappings []menuPath } type MenuItem struct { ID int MenuID int Name string HTMLID string CSSClass string Position string Path string Aria string Tooltip string Order int TmplName string GuestOnly bool MemberOnly bool SuperModOnly bool AdminOnly bool } // TODO: Move the menu item stuff to it's own file type MenuItemStmts struct { update *sql.Stmt insert *sql.Stmt delete *sql.Stmt updateOrder *sql.Stmt } var menuItemStmts MenuItemStmts func init() { DbInits.Add(func(acc *qgen.Accumulator) error { mi := "menu_items" menuItemStmts = MenuItemStmts{ update: acc.Update(mi).Set("name=?,htmlID=?,cssClass=?,position=?,path=?,aria=?,tooltip=?,tmplName=?,guestOnly=?,memberOnly=?,staffOnly=?,adminOnly=?").Where("miid=?").Prepare(), insert: acc.Insert(mi).Columns("mid, name, htmlID, cssClass, position, path, aria, tooltip, tmplName, guestOnly, memberOnly, staffOnly, adminOnly").Fields("?,?,?,?,?,?,?,?,?,?,?,?,?").Prepare(), delete: acc.Delete(mi).Where("miid=?").Prepare(), updateOrder: acc.Update(mi).Set("order=?").Where("miid=?").Prepare(), } return acc.FirstError() }) } func (i MenuItem) Commit() error { _, e := menuItemStmts.update.Exec(i.Name, i.HTMLID, i.CSSClass, i.Position, i.Path, i.Aria, i.Tooltip, i.TmplName, i.GuestOnly, i.MemberOnly, i.SuperModOnly, i.AdminOnly, i.ID) Menus.Load(i.MenuID) return e } func (i MenuItem) Create() (int, error) { res, e := menuItemStmts.insert.Exec(i.MenuID, i.Name, i.HTMLID, i.CSSClass, i.Position, i.Path, i.Aria, i.Tooltip, i.TmplName, i.GuestOnly, i.MemberOnly, i.SuperModOnly, i.AdminOnly) if e != nil { return 0, e } Menus.Load(i.MenuID) miid64, e := res.LastInsertId() return int(miid64), e } func (i MenuItem) Delete() error { _, e := menuItemStmts.delete.Exec(i.ID) Menus.Load(i.MenuID) return e } func (h *MenuListHolder) LoadTmpl(name string) (t MenuTmpl, e error) { data, e := ioutil.ReadFile("./templates/" + name + ".html") if e != nil { return t, e } return h.Parse(name, []byte(tmpl.Minify(string(data)))), nil } // TODO: Make this atomic, maybe with a transaction or store the order on the menu itself? func (h *MenuListHolder) UpdateOrder(updateMap map[int]int) error { for miid, order := range updateMap { _, e := menuItemStmts.updateOrder.Exec(order, miid) if e != nil { return e } } Menus.Load(h.MenuID) return nil } func (h *MenuListHolder) LoadTmpls() (tmpls map[string]MenuTmpl, e error) { tmpls = make(map[string]MenuTmpl) load := func(name string) error { menuTmpl, e := h.LoadTmpl(name) if e != nil { return e } tmpls[name] = menuTmpl return nil } e = load("menu_item") if e != nil { return tmpls, e } e = load("menu_alerts") return tmpls, e } // TODO: Run this in main, sync ticks, when the phrase file changes (need to implement the sync for that first), and when the settings are changed func (h *MenuListHolder) Preparse() error { tmpls, err := h.LoadTmpls() if err != nil { return err } addVariation := func(index int, callback func(i MenuItem) bool) { renderBuffer, variableIndices, pathList := h.Scan(tmpls, callback) h.Variations[index] = menuTmpl{renderBuffer, variableIndices, pathList} } // Guest Menu addVariation(0, func(i MenuItem) bool { return !i.MemberOnly }) // Member Menu addVariation(1, func(i MenuItem) bool { return !i.SuperModOnly && !i.GuestOnly }) // Super Mod Menu addVariation(2, func(i MenuItem) bool { return !i.AdminOnly && !i.GuestOnly }) // Admin Menu addVariation(3, func(i MenuItem) bool { return !i.GuestOnly }) return nil } func nextCharIs(tmplData []byte, i int, expects byte) bool { if len(tmplData) <= (i + 1) { return false } return tmplData[i+1] == expects } func peekNextChar(tmplData []byte, i int) byte { if len(tmplData) <= (i + 1) { return 0 } return tmplData[i+1] } func skipUntilIfExists(tmplData []byte, i int, expects byte) (newI int, hasIt bool) { j := i for ; j < len(tmplData); j++ { if tmplData[j] == expects { return j, true } } return j, false } func skipUntilIfExistsOrLine(tmplData []byte, i int, expects byte) (newI int, hasIt bool) { j := i for ; j < len(tmplData); j++ { if tmplData[j] == 10 { return j, false } else if tmplData[j] == expects { return j, true } } return j, false } func skipUntilCharsExist(tmplData []byte, i int, expects []byte) (newI int, hasIt bool) { j := i expectIndex := 0 for ; j < len(tmplData) && expectIndex < len(expects); j++ { //fmt.Println("tmplData[j]: ", string(tmplData[j])) if tmplData[j] != expects[expectIndex] { return j, false } //fmt.Printf("found %+v at %d\n", string(expects[expectIndex]), expectIndex) expectIndex++ } return j, true } func skipAllUntilCharsExist(tmplData []byte, i int, expects []byte) (newI int, hasIt bool) { j := i expectIndex := 0 for ; j < len(tmplData) && expectIndex < len(expects); j++ { if tmplData[j] == expects[expectIndex] { //fmt.Printf("expects[expectIndex]: %+v - %d\n", string(expects[expectIndex]), expectIndex) expectIndex++ if len(expects) <= expectIndex { break } } else { /*if expectIndex != 0 { fmt.Println("broke expectations") fmt.Println("expected: ", string(expects[expectIndex])) fmt.Println("got: ", string(tmplData[j])) fmt.Println("next: ", string(peekNextChar(tmplData, j))) fmt.Println("next: ", string(peekNextChar(tmplData, j+1))) fmt.Println("next: ", string(peekNextChar(tmplData, j+2))) fmt.Println("next: ", string(peekNextChar(tmplData, j+3))) }*/ expectIndex = 0 } } return j, len(expects) == expectIndex } type menuRenderItem struct { Type int // 0: text, 1: variable Index int } type MenuTmpl struct { Name string TextBuffer [][]byte VariableBuffer [][]byte RenderList []menuRenderItem } func menuDumpSlice(outerSlice [][]byte) { for sliceID, slice := range outerSlice { fmt.Print(strconv.Itoa(sliceID) + ":[") for _, ch := range slice { fmt.Print(string(ch)) } fmt.Print("] ") } } func (h *MenuListHolder) Parse(name string, tmplData []byte) (menuTmpl MenuTmpl) { var textBuffer, variableBuffer [][]byte var renderList []menuRenderItem var subBuffer []byte // ? We only support simple properties on MenuItem right now addVariable := func(name []byte) { // TODO: Check if the subBuffer has any items or is empty textBuffer = append(textBuffer, subBuffer) subBuffer = nil variableBuffer = append(variableBuffer, name) renderList = append(renderList, menuRenderItem{0, len(textBuffer) - 1}) renderList = append(renderList, menuRenderItem{1, len(variableBuffer) - 1}) } tmplData = bytes.Replace(tmplData, []byte("{{"), []byte("{"), -1) tmplData = bytes.Replace(tmplData, []byte("}}"), []byte("}}"), -1) for i := 0; i < len(tmplData); i++ { char := tmplData[i] if char == '{' { dotIndex, hasDot := skipUntilIfExists(tmplData, i, '.') if !hasDot { // Template function style langIndex, hasChars := skipUntilCharsExist(tmplData, i+1, []byte("lang")) if hasChars { startIndex, hasStart := skipUntilIfExists(tmplData, langIndex, '"') endIndex, hasEnd := skipUntilIfExists(tmplData, startIndex+1, '"') if hasStart && hasEnd { fenceIndex, hasFence := skipUntilIfExists(tmplData, endIndex, '}') if !hasFence || !nextCharIs(tmplData, fenceIndex, '}') { break } //fmt.Println("tmplData[startIndex:endIndex]: ", tmplData[startIndex+1:endIndex]) prefix := []byte("lang.") addVariable(append(prefix, tmplData[startIndex+1:endIndex]...)) i = fenceIndex + 1 continue } } break } fenceIndex, hasFence := skipUntilIfExists(tmplData, dotIndex, '}') if !hasFence { break } addVariable(tmplData[dotIndex:fenceIndex]) i = fenceIndex + 1 continue } subBuffer = append(subBuffer, char) } if len(subBuffer) > 0 { // TODO: Have a property in renderList which holds the byte slice since variableBuffers and textBuffers have the same underlying implementation? textBuffer = append(textBuffer, subBuffer) renderList = append(renderList, menuRenderItem{0, len(textBuffer) - 1}) } return MenuTmpl{name, textBuffer, variableBuffer, renderList} } func (h *MenuListHolder) Scan(tmpls map[string]MenuTmpl, showItem func(i MenuItem) bool) (renderBuffer [][]byte, variableIndices []int, pathList []menuPath) { for _, mitem := range h.List { // Do we want this item in this variation of the menu? if !showItem(mitem) { continue } renderBuffer, variableIndices = h.ScanItem(tmpls, mitem, renderBuffer, variableIndices) pathList = append(pathList, menuPath{mitem.Path, len(renderBuffer) - 1}) } // TODO: Need more coalescing in the renderBuffer return renderBuffer, variableIndices, pathList } // Note: This doesn't do a visibility check like hold.Scan() does func (h *MenuListHolder) ScanItem(tmpls map[string]MenuTmpl, mitem MenuItem, renderBuffer [][]byte, variableIndices []int) ([][]byte, []int) { menuTmpl, ok := tmpls[mitem.TmplName] if !ok { menuTmpl = tmpls["menu_item"] } for _, renderItem := range menuTmpl.RenderList { if renderItem.Type == 0 { renderBuffer = append(renderBuffer, menuTmpl.TextBuffer[renderItem.Index]) continue } variable := menuTmpl.VariableBuffer[renderItem.Index] dotAt, hasDot := skipUntilIfExists(variable, 0, '.') if !hasDot { continue } if bytes.Equal(variable[:dotAt], []byte("lang")) { renderBuffer = append(renderBuffer, []byte(phrases.GetTmplPhrase(string(bytes.TrimPrefix(variable[dotAt:], []byte(".")))))) continue } var renderItem []byte switch string(variable) { case ".ID": renderItem = []byte(strconv.Itoa(mitem.ID)) case ".Name": renderItem = []byte(mitem.Name) case ".HTMLID": renderItem = []byte(mitem.HTMLID) case ".CSSClass": renderItem = []byte(mitem.CSSClass) case ".Position": renderItem = []byte(mitem.Position) case ".Path": renderItem = []byte(mitem.Path) case ".Aria": renderItem = []byte(mitem.Aria) case ".Tooltip": renderItem = []byte(mitem.Tooltip) case ".CSSActive": renderItem = []byte("{dyn.active}") } _, hasInnerVar := skipUntilIfExists(renderItem, 0, '{') if hasInnerVar { DebugLog("inner var: ", string(renderItem)) dotAt, hasDot := skipUntilIfExists(renderItem, 0, '.') endFence, hasEndFence := skipUntilIfExists(renderItem, dotAt, '}') if !hasDot || !hasEndFence || (endFence-dotAt) <= 1 { renderBuffer = append(renderBuffer, renderItem) variableIndices = append(variableIndices, len(renderBuffer)-1) continue } if bytes.Equal(renderItem[1:dotAt], []byte("lang")) { //fmt.Println("lang var: ", string(renderItem[dotAt+1:endFence])) renderBuffer = append(renderBuffer, []byte(phrases.GetTmplPhrase(string(renderItem[dotAt+1:endFence])))) } else { fmt.Println("other var: ", string(variable[:dotAt])) if len(renderItem) > 0 { renderBuffer = append(renderBuffer, renderItem) variableIndices = append(variableIndices, len(renderBuffer)-1) } } continue } if len(renderItem) > 0 { renderBuffer = append(renderBuffer, renderItem) } } return renderBuffer, variableIndices } // TODO: Pre-render the lang stuff func (h *MenuListHolder) Build(w io.Writer, user *User, pathPrefix string) error { var mTmpl menuTmpl if !user.Loggedin { mTmpl = h.Variations[0] } else if user.IsAdmin { mTmpl = h.Variations[3] } else if user.IsSuperMod { mTmpl = h.Variations[2] } else { mTmpl = h.Variations[1] } if pathPrefix == "" { pathPrefix = Config.DefaultPath } if len(mTmpl.VariableIndices) == 0 { for _, renderItem := range mTmpl.RenderBuffer { w.Write(renderItem) } return nil } nearIndex := 0 for index, renderItem := range mTmpl.RenderBuffer { if index != mTmpl.VariableIndices[nearIndex] { w.Write(renderItem) continue } variable := renderItem // ? - I can probably remove this check now that I've kicked it upstream, or we could keep it here for safety's sake? if len(variable) == 0 { continue } prevIndex := 0 for i := 0; i < len(renderItem); i++ { fenceStart, hasFence := skipUntilIfExists(variable, i, '{') if !hasFence { continue } i = fenceStart fenceEnd, hasFence := skipUntilIfExists(variable, fenceStart, '}') if !hasFence { continue } i = fenceEnd dotAt, hasDot := skipUntilIfExists(variable, fenceStart, '.') if !hasDot { continue } switch string(variable[fenceStart+1 : dotAt]) { case "me": w.Write(variable[prevIndex:fenceStart]) switch string(variable[dotAt+1 : fenceEnd]) { case "Link": w.Write([]byte(user.Link)) case "Session": w.Write([]byte(user.Session)) } prevIndex = fenceEnd // TODO: Optimise this case "dyn": w.Write(variable[prevIndex:fenceStart]) var pmi int for ii, pathItem := range mTmpl.PathMappings { pmi = ii if pathItem.Index > index { break } } if len(mTmpl.PathMappings) != 0 { path := mTmpl.PathMappings[pmi].Path if path == "" || path == "/" { path = Config.DefaultPath } if strings.HasPrefix(path, pathPrefix) { w.Write([]byte(" menu_active")) } } prevIndex = fenceEnd } } w.Write(variable[prevIndex : len(variable)-1]) if len(mTmpl.VariableIndices) > (nearIndex + 1) { nearIndex++ } } return nil } ================================================ FILE: common/meta/meta_store.go ================================================ package common import ( "database/sql" qgen "github.com/Azareal/Gosora/query_gen" ) // MetaStore is a simple key-value store for the system to stash things in when needed type MetaStore interface { Get(name string) (val string, err error) Set(name, val string) error SetInt(name string, val int) error SetInt64(name string, val int64) error } type DefaultMetaStore struct { get *sql.Stmt set *sql.Stmt add *sql.Stmt } func NewDefaultMetaStore(acc *qgen.Accumulator) (*DefaultMetaStore, error) { t := "meta" m := &DefaultMetaStore{ get: acc.Select(t).Columns("value").Where("name=?").Prepare(), set: acc.Update(t).Set("value=?").Where("name=?").Prepare(), add: acc.Insert(t).Columns("name,value").Fields("?,''").Prepare(), } return m, acc.FirstError() } func (s *DefaultMetaStore) Get(name string) (val string, e error) { e = s.get.QueryRow(name).Scan(&val) return val, e } // TODO: Use timestamped rows as a more robust method of ensuring data integrity func (s *DefaultMetaStore) setVal(name string, val interface{}) error { _, e := s.Get(name) if e == sql.ErrNoRows { _, e := s.add.Exec(name) if e != nil { return e } } _, e = s.set.Exec(val, name) return e } func (s *DefaultMetaStore) Set(name, val string) error { return s.setVal(name, val) } func (s *DefaultMetaStore) SetInt(name string, val int) error { return s.setVal(name, val) } func (s *DefaultMetaStore) SetInt64(name string, val int64) error { return s.setVal(name, val) } ================================================ FILE: common/mfa_store.go ================================================ package common import ( "database/sql" "errors" "strings" qgen "github.com/Azareal/Gosora/query_gen" ) var MFAstore MFAStore var ErrMFAScratchIndexOutOfBounds = errors.New("That MFA scratch index is out of bounds") type MFAItemStmts struct { update *sql.Stmt delete *sql.Stmt } var mfaItemStmts MFAItemStmts func init() { DbInits.Add(func(acc *qgen.Accumulator) error { mfaItemStmts = MFAItemStmts{ update: acc.Update("users_2fa_keys").Set("scratch1=?,scratch2=?,scratch3=?,scratch4=?,scratch5=?,scratch6=?,scratch7=?,scratch8=?").Where("uid=?").Prepare(), delete: acc.Delete("users_2fa_keys").Where("uid=?").Prepare(), } return acc.FirstError() }) } type MFAItem struct { UID int Secret string Scratch []string } func (i *MFAItem) BurnScratch(index int) error { if index < 0 || len(i.Scratch) <= index { return ErrMFAScratchIndexOutOfBounds } newScratch, err := mfaCreateScratch() if err != nil { return err } i.Scratch[index] = newScratch _, err = mfaItemStmts.update.Exec(i.Scratch[0], i.Scratch[1], i.Scratch[2], i.Scratch[3], i.Scratch[4], i.Scratch[5], i.Scratch[6], i.Scratch[7], i.UID) return err } func (i *MFAItem) Delete() error { _, err := mfaItemStmts.delete.Exec(i.UID) return err } func mfaCreateScratch() (string, error) { code, err := GenerateStd32SafeString(8) return strings.Replace(code, "=", "", -1), err } type MFAStore interface { Get(id int) (*MFAItem, error) Create(secret string, uid int) (err error) } type SQLMFAStore struct { get *sql.Stmt create *sql.Stmt } func NewSQLMFAStore(acc *qgen.Accumulator) (*SQLMFAStore, error) { return &SQLMFAStore{ get: acc.Select("users_2fa_keys").Columns("secret,scratch1,scratch2,scratch3,scratch4,scratch5,scratch6,scratch7,scratch8").Where("uid=?").Prepare(), create: acc.Insert("users_2fa_keys").Columns("uid,secret,scratch1,scratch2,scratch3,scratch4,scratch5,scratch6,scratch7,scratch8,createdAt").Fields("?,?,?,?,?,?,?,?,?,?,UTC_TIMESTAMP()").Prepare(), }, acc.FirstError() } // TODO: Write a test for this func (s *SQLMFAStore) Get(id int) (*MFAItem, error) { i := MFAItem{UID: id, Scratch: make([]string, 8)} err := s.get.QueryRow(id).Scan(&i.Secret, &i.Scratch[0], &i.Scratch[1], &i.Scratch[2], &i.Scratch[3], &i.Scratch[4], &i.Scratch[5], &i.Scratch[6], &i.Scratch[7]) return &i, err } // TODO: Write a test for this func (s *SQLMFAStore) Create(secret string, uid int) (err error) { params := make([]interface{}, 10) params[0] = uid params[1] = secret for i := 2; i < len(params); i++ { code, err := mfaCreateScratch() if err != nil { return err } params[i] = code } _, err = s.create.Exec(params...) return err } ================================================ FILE: common/misc_logs.go ================================================ package common import ( "database/sql" "time" qgen "github.com/Azareal/Gosora/query_gen" ) var RegLogs RegLogStore var LoginLogs LoginLogStore type RegLogItem struct { ID int Username string Email string FailureReason string Success bool IP string DoneAt string } type RegLogStmts struct { update *sql.Stmt create *sql.Stmt } var regLogStmts RegLogStmts func init() { DbInits.Add(func(acc *qgen.Accumulator) error { rl := "registration_logs" regLogStmts = RegLogStmts{ update: acc.Update(rl).Set("username=?,email=?,failureReason=?,success=?,doneAt=?").Where("rlid=?").Prepare(), create: acc.Insert(rl).Columns("username,email,failureReason,success,ipaddress,doneAt").Fields("?,?,?,?,?,UTC_TIMESTAMP()").Prepare(), } return acc.FirstError() }) } // TODO: Reload this item in the store, probably doesn't matter right now, but it might when we start caching this stuff in memory // ! Retroactive updates of date are not permitted for integrity reasons // TODO: Do we even use this anymore or can we just make the logs immutable (except for deletes) for simplicity sake? func (l *RegLogItem) Commit() error { _, e := regLogStmts.update.Exec(l.Username, l.Email, l.FailureReason, l.Success, l.DoneAt, l.ID) return e } func (l *RegLogItem) Create() (id int, e error) { id, e = Createf(regLogStmts.create, l.Username, l.Email, l.FailureReason, l.Success, l.IP) l.ID = id return l.ID, e } type RegLogStore interface { Count() (count int) GetOffset(offset, perPage int) (logs []RegLogItem, err error) Purge() error DeleteOlderThanDays(days int) error } type SQLRegLogStore struct { count *sql.Stmt getOffset *sql.Stmt purge *sql.Stmt deleteOlderThanDays *sql.Stmt } func NewRegLogStore(acc *qgen.Accumulator) (*SQLRegLogStore, error) { rl := "registration_logs" return &SQLRegLogStore{ count: acc.Count(rl).Prepare(), getOffset: acc.Select(rl).Columns("rlid,username,email,failureReason,success,ipaddress,doneAt").Orderby("doneAt DESC").Limit("?,?").Prepare(), purge: acc.Purge(rl), deleteOlderThanDays: acc.Delete(rl).DateOlderThanQ("doneAt", "day").Prepare(), }, acc.FirstError() } func (s *SQLRegLogStore) Count() (count int) { return Count(s.count) } func (s *SQLRegLogStore) GetOffset(offset, perPage int) (logs []RegLogItem, e error) { rows, e := s.getOffset.Query(offset, perPage) if e != nil { return logs, e } defer rows.Close() for rows.Next() { var l RegLogItem var doneAt time.Time e := rows.Scan(&l.ID, &l.Username, &l.Email, &l.FailureReason, &l.Success, &l.IP, &doneAt) if e != nil { return logs, e } l.DoneAt = doneAt.Format("2006-01-02 15:04:05") logs = append(logs, l) } return logs, rows.Err() } func (s *SQLRegLogStore) DeleteOlderThanDays(days int) error { _, e := s.deleteOlderThanDays.Exec(days) return e } // Delete all registration logs func (s *SQLRegLogStore) Purge() error { _, e := s.purge.Exec() return e } type LoginLogItem struct { ID int UID int Success bool IP string DoneAt string } type LoginLogStmts struct { update *sql.Stmt create *sql.Stmt } var loginLogStmts LoginLogStmts func init() { DbInits.Add(func(acc *qgen.Accumulator) error { ll := "login_logs" loginLogStmts = LoginLogStmts{ update: acc.Update(ll).Set("uid=?,success=?,doneAt=?").Where("lid=?").Prepare(), create: acc.Insert(ll).Columns("uid,success,ipaddress,doneAt").Fields("?,?,?,UTC_TIMESTAMP()").Prepare(), } return acc.FirstError() }) } // TODO: Reload this item in the store, probably doesn't matter right now, but it might when we start caching this stuff in memory // ! Retroactive updates of date are not permitted for integrity reasons func (l *LoginLogItem) Commit() error { _, e := loginLogStmts.update.Exec(l.UID, l.Success, l.DoneAt, l.ID) return e } func (l *LoginLogItem) Create() (id int, e error) { res, e := loginLogStmts.create.Exec(l.UID, l.Success, l.IP) if e != nil { return 0, e } id64, e := res.LastInsertId() l.ID = int(id64) return l.ID, e } type LoginLogStore interface { Count() (count int) CountUser(uid int) (count int) GetOffset(uid, offset, perPage int) (logs []LoginLogItem, err error) Purge() error DeleteOlderThanDays(days int) error } type SQLLoginLogStore struct { count *sql.Stmt countForUser *sql.Stmt getOffsetByUser *sql.Stmt purge *sql.Stmt deleteOlderThanDays *sql.Stmt } func NewLoginLogStore(acc *qgen.Accumulator) (*SQLLoginLogStore, error) { ll := "login_logs" return &SQLLoginLogStore{ count: acc.Count(ll).Prepare(), countForUser: acc.Count(ll).Where("uid=?").Prepare(), getOffsetByUser: acc.Select(ll).Columns("lid,success,ipaddress,doneAt").Where("uid=?").Orderby("doneAt DESC").Limit("?,?").Prepare(), purge: acc.Purge(ll), deleteOlderThanDays: acc.Delete(ll).DateOlderThanQ("doneAt", "day").Prepare(), }, acc.FirstError() } func (s *SQLLoginLogStore) Count() (count int) { return Count(s.count) } func (s *SQLLoginLogStore) CountUser(uid int) (count int) { return Countf(s.countForUser, uid) } func (s *SQLLoginLogStore) GetOffset(uid, offset, perPage int) (logs []LoginLogItem, e error) { rows, e := s.getOffsetByUser.Query(uid, offset, perPage) if e != nil { return logs, e } defer rows.Close() for rows.Next() { l := LoginLogItem{UID: uid} var doneAt time.Time e := rows.Scan(&l.ID, &l.Success, &l.IP, &doneAt) if e != nil { return logs, e } l.DoneAt = doneAt.Format("2006-01-02 15:04:05") logs = append(logs, l) } return logs, rows.Err() } func (s *SQLLoginLogStore) DeleteOlderThanDays(days int) error { _, e := s.deleteOlderThanDays.Exec(days) return e } // Delete all login logs func (s *SQLLoginLogStore) Purge() error { _, e := s.purge.Exec() return e } ================================================ FILE: common/module_ottojs.go ================================================ /* * * OttoJS Plugin Module * Copyright Azareal 2016 - 2019 * */ package common import ( "errors" "github.com/robertkrimen/otto" ) type OttoPluginLang struct { vm *otto.Otto plugins map[string]*otto.Script vars map[string]*otto.Object } func init() { pluginLangs["ottojs"] = &OttoPluginLang{ plugins: make(map[string]*otto.Script), vars: make(map[string]*otto.Object), } } func (js *OttoPluginLang) Init() (err error) { js.vm = otto.New() js.vars["current_page"], err = js.vm.Object(`var current_page = {}`) return err } func (js *OttoPluginLang) GetName() string { return "ottojs" } func (js *OttoPluginLang) GetExts() []string { return []string{".js"} } func (js *OttoPluginLang) AddPlugin(meta PluginMeta) (plugin *Plugin, err error) { script, err := js.vm.Compile("./extend/"+meta.UName+"/"+meta.Main, nil) if err != nil { return nil, err } var pluginInit = func(plugin *Plugin) error { retValue, err := js.vm.Run(script) if err != nil { return err } if retValue.IsString() { ret, err := retValue.ToString() if err != nil { return err } if ret != "" { return errors.New(ret) } } return nil } plugin = new(Plugin) plugin.UName = meta.UName plugin.Name = meta.Name plugin.Author = meta.Author plugin.URL = meta.URL plugin.Settings = meta.Settings plugin.Tag = meta.Tag plugin.Type = "ottojs" plugin.Init = pluginInit // TODO: Implement plugin life cycle events buildPlugin(plugin) plugin.Data = script return plugin, nil } /*func (js *OttoPluginLang) addHook(hook string, plugin string) { hooks[hook] = func(data interface{}) interface{} { switch d := data.(type) { case Page: currentPage := js.vars["current_page"] currentPage.Set("Title", d.Title) case TopicPage: case ProfilePage: case Reply: default: log.Print("Not a valid JS datatype") } } }*/ ================================================ FILE: common/no_websockets.go ================================================ // +build no_ws package common import "errors" import "net/http" // TODO: Disable WebSockets on high load? Add a Control Panel interface for disabling it? var EnableWebsockets = false // Put this in caps for consistency with the other constants? var wsHub WSHub var errWsNouser = errors.New("This user isn't connected via WebSockets") type WSHub struct{} func (_ *WSHub) guestCount() int { return 0 } func (_ *WSHub) userCount() int { return 0 } func (hub *WSHub) broadcastMessage(_ string) error { return nil } func (hub *WSHub) pushMessage(_ int, _ string) error { return errWsNouser } func (hub *WSHub) pushAlert(_ int, _ int, _ string, _ string, _ int, _ int, _ int) error { return errWsNouser } func (hub *WSHub) pushAlerts(_ []int, _ int, _ string, _ string, _ int, _ int, _ int) error { return errWsNouser } func RouteWebsockets(_ http.ResponseWriter, _ *http.Request, _ User) {} ================================================ FILE: common/null_reply_cache.go ================================================ package common // NullReplyCache is a reply cache to be used when you don't want a cache and just want queries to passthrough to the database type NullReplyCache struct { } // NewNullReplyCache gives you a new instance of NullReplyCache func NewNullReplyCache() *NullReplyCache { return &NullReplyCache{} } // nolint func (c *NullReplyCache) Get(id int) (*Reply, error) { return nil, ErrNoRows } func (c *NullReplyCache) GetUnsafe(id int) (*Reply, error) { return nil, ErrNoRows } func (c *NullReplyCache) BulkGet(ids []int) (list []*Reply) { return make([]*Reply, len(ids)) } func (c *NullReplyCache) Set(_ *Reply) error { return nil } func (c *NullReplyCache) Add(_ *Reply) error { return nil } func (c *NullReplyCache) AddUnsafe(_ *Reply) error { return nil } func (c *NullReplyCache) Remove(id int) error { return nil } func (c *NullReplyCache) RemoveUnsafe(id int) error { return nil } func (c *NullReplyCache) Flush() { } func (c *NullReplyCache) Length() int { return 0 } func (c *NullReplyCache) SetCapacity(_ int) { } func (c *NullReplyCache) GetCapacity() int { return 0 } ================================================ FILE: common/null_topic_cache.go ================================================ package common // NullTopicCache is a topic cache to be used when you don't want a cache and just want queries to passthrough to the database type NullTopicCache struct { } // NewNullTopicCache gives you a new instance of NullTopicCache func NewNullTopicCache() *NullTopicCache { return &NullTopicCache{} } // nolint func (c *NullTopicCache) Get(id int) (*Topic, error) { return nil, ErrNoRows } func (c *NullTopicCache) GetUnsafe(id int) (*Topic, error) { return nil, ErrNoRows } func (c *NullTopicCache) BulkGet(ids []int) (list []*Topic) { return make([]*Topic, len(ids)) } func (c *NullTopicCache) Set(_ *Topic) error { return nil } func (c *NullTopicCache) Add(_ *Topic) error { return nil } func (c *NullTopicCache) AddUnsafe(_ *Topic) error { return nil } func (c *NullTopicCache) Remove(id int) error { return nil } func (c *NullTopicCache) RemoveMany(ids []int) error { return nil } func (c *NullTopicCache) RemoveUnsafe(id int) error { return nil } func (c *NullTopicCache) Flush() { } func (c *NullTopicCache) Length() int { return 0 } func (c *NullTopicCache) SetCapacity(_ int) { } func (c *NullTopicCache) GetCapacity() int { return 0 } ================================================ FILE: common/null_user_cache.go ================================================ package common // NullUserCache is a user cache to be used when you don't want a cache and just want queries to passthrough to the database type NullUserCache struct { } // NewNullUserCache gives you a new instance of NullUserCache func NewNullUserCache() *NullUserCache { return &NullUserCache{} } // nolint func (c *NullUserCache) DeallocOverflow(evictPriority bool) (evicted int) { return 0 } func (c *NullUserCache) Get(id int) (*User, error) { return nil, ErrNoRows } func (c *NullUserCache) Getn(id int) *User { return nil } func (c *NullUserCache) BulkGet(ids []int) (list []*User) { return make([]*User, len(ids)) } func (c *NullUserCache) GetUnsafe(id int) (*User, error) { return nil, ErrNoRows } func (c *NullUserCache) Set(_ *User) error { return nil } func (c *NullUserCache) Add(_ *User) error { return nil } func (c *NullUserCache) AddUnsafe(_ *User) error { return nil } func (c *NullUserCache) Remove(id int) error { return nil } func (c *NullUserCache) RemoveUnsafe(id int) error { return nil } func (c *NullUserCache) BulkRemove(ids []int) {} func (c *NullUserCache) Flush() { } func (c *NullUserCache) Length() int { return 0 } func (c *NullUserCache) SetCapacity(_ int) { } func (c *NullUserCache) GetCapacity() int { return 0 } ================================================ FILE: common/page_store.go ================================================ package common import ( "database/sql" "strconv" "strings" qgen "github.com/Azareal/Gosora/query_gen" ) type CustomPageStmts struct { update *sql.Stmt create *sql.Stmt } var customPageStmts CustomPageStmts func init() { DbInits.Add(func(acc *qgen.Accumulator) error { customPageStmts = CustomPageStmts{ update: acc.Update("pages").Set("name=?,title=?,body=?,allowedGroups=?,menuID=?").Where("pid=?").Prepare(), create: acc.Insert("pages").Columns("name,title,body,allowedGroups,menuID").Fields("?,?,?,?,?").Prepare(), } return acc.FirstError() }) } type CustomPage struct { ID int Name string // TODO: Let admins put pages in "virtual subdirectories" Title string Body string AllowedGroups []int MenuID int } func BlankCustomPage() *CustomPage { return new(CustomPage) } func (p *CustomPage) AddAllowedGroup(gid int) { p.AllowedGroups = append(p.AllowedGroups, gid) } func (p *CustomPage) getRawAllowedGroups() (rawAllowedGroups string) { for _, group := range p.AllowedGroups { rawAllowedGroups += strconv.Itoa(group) + "," } if len(rawAllowedGroups) > 0 { rawAllowedGroups = rawAllowedGroups[:len(rawAllowedGroups)-1] } return rawAllowedGroups } func (p *CustomPage) Commit() error { _, err := customPageStmts.update.Exec(p.Name, p.Title, p.Body, p.getRawAllowedGroups(), p.MenuID, p.ID) Pages.Reload(p.ID) return err } func (p *CustomPage) Create() (int, error) { res, err := customPageStmts.create.Exec(p.Name, p.Title, p.Body, p.getRawAllowedGroups(), p.MenuID) if err != nil { return 0, err } pid64, err := res.LastInsertId() return int(pid64), err } var Pages PageStore // Holds the custom pages, but doesn't include the template pages in /pages/ which are a lot more flexible yet harder to use and which are too risky security-wise to make editable in the Control Panel type PageStore interface { Count() (count int) Get(id int) (*CustomPage, error) GetByName(name string) (*CustomPage, error) GetOffset(offset, perPage int) (pages []*CustomPage, err error) Reload(id int) error Delete(id int) error } // TODO: Add a cache to this to save on the queries type DefaultPageStore struct { get *sql.Stmt getByName *sql.Stmt getOffset *sql.Stmt count *sql.Stmt delete *sql.Stmt } func NewDefaultPageStore(acc *qgen.Accumulator) (*DefaultPageStore, error) { pa := "pages" allCols := "pid, name, title, body, allowedGroups, menuID" return &DefaultPageStore{ get: acc.Select(pa).Columns("name, title, body, allowedGroups, menuID").Where("pid=?").Prepare(), getByName: acc.Select(pa).Columns(allCols).Where("name=?").Prepare(), getOffset: acc.Select(pa).Columns(allCols).Orderby("pid DESC").Limit("?,?").Prepare(), count: acc.Count(pa).Prepare(), delete: acc.Delete(pa).Where("pid=?").Prepare(), }, acc.FirstError() } func (s *DefaultPageStore) Count() (count int) { err := s.count.QueryRow().Scan(&count) if err != nil { LogError(err) } return count } func (s *DefaultPageStore) parseAllowedGroups(raw string, page *CustomPage) error { if raw == "" { return nil } for _, sgroup := range strings.Split(raw, ",") { group, err := strconv.Atoi(sgroup) if err != nil { return err } page.AddAllowedGroup(group) } return nil } func (s *DefaultPageStore) Get(id int) (*CustomPage, error) { p := &CustomPage{ID: id} rawAllowedGroups := "" err := s.get.QueryRow(id).Scan(&p.Name, &p.Title, &p.Body, &rawAllowedGroups, &p.MenuID) if err != nil { return nil, err } return p, s.parseAllowedGroups(rawAllowedGroups, p) } func (s *DefaultPageStore) GetByName(name string) (*CustomPage, error) { p := BlankCustomPage() rawAllowedGroups := "" err := s.getByName.QueryRow(name).Scan(&p.ID, &p.Name, &p.Title, &p.Body, &rawAllowedGroups, &p.MenuID) if err != nil { return nil, err } return p, s.parseAllowedGroups(rawAllowedGroups, p) } func (s *DefaultPageStore) GetOffset(offset, perPage int) (pages []*CustomPage, err error) { rows, err := s.getOffset.Query(offset, perPage) if err != nil { return pages, err } defer rows.Close() for rows.Next() { p := &CustomPage{ID: 0} rawAllowedGroups := "" err := rows.Scan(&p.ID, &p.Name, &p.Title, &p.Body, &rawAllowedGroups, &p.MenuID) if err != nil { return pages, err } err = s.parseAllowedGroups(rawAllowedGroups, p) if err != nil { return pages, err } pages = append(pages, p) } return pages, rows.Err() } // Always returns nil as there's currently no cache func (s *DefaultPageStore) Reload(id int) error { return nil } func (s *DefaultPageStore) Delete(id int) error { _, err := s.delete.Exec(id) return err } ================================================ FILE: common/pages.go ================================================ package common import ( "html/template" "net/http" "runtime" "sync" "time" p "github.com/Azareal/Gosora/common/phrases" ) /*type HResource struct { Name string Hash string }*/ // TODO: Allow resources in spots other than /s/ and possibly even external domains (e.g. CDNs) // TODO: Preload Trumboyg on Cosora on the forum list type Header struct { Title string //Title []byte // Experimenting with []byte for increased efficiency, let's avoid converting too many things to []byte, as it involves a lot of extra boilerplate NoticeList []string Scripts []HScript PreScriptsAsync []HScript ScriptsAsync []HScript //Preload []string Stylesheets []HScript Widgets PageWidgets Site *site Settings SettingMap //Themes map[string]*Theme // TODO: Use a slice containing every theme instead of the main map for speed? ThemesSlice []*Theme Theme *Theme //TemplateName string // TODO: Use this to move template calls to the router rather than duplicating them over and over and over? CurrentUser *User // TODO: Deprecate CurrentUser on the page structs and use a pointer here Hooks *HookTable Zone string ZoneID int ZoneData interface{} Path string MetaDesc string //OGImage string OGDesc string GoogSiteVerify string IsoCode string LooseCSP bool ExternalMedia bool //StartedAt time.Time StartedAt int64 Elapsed1 string Writer http.ResponseWriter ExtData ExtData } type HScript struct { Name string Hash string } func (h *Header) getScript(name string) HScript { if name[0] == '/' && name[1] == '/' { } else { file, ok := StaticFiles.GetShort(name) if ok { return HScript{file.OName, file.Sha256I} } } return HScript{name, ""} } func (h *Header) AddScript(name string) { //log.Print("name:", name) h.Scripts = append(h.Scripts, h.getScript(name)) } func (h *Header) AddPreScriptAsync(name string) { h.PreScriptsAsync = append(h.PreScriptsAsync, h.getScript(name)) } func (h *Header) AddScriptAsync(name string) { h.ScriptsAsync = append(h.ScriptsAsync, h.getScript(name)) } /*func (h *Header) Preload(name string) { h.Preload = append(h.Preload, name) }*/ func (h *Header) AddSheet(name string) { h.Stylesheets = append(h.Stylesheets, h.getScript(name)) } // ! Experimental func (h *Header) AddXRes(names ...string) { var o string for i, name := range names { if name[0] == '/' && name[1] == '/' { } else { file, ok := StaticFiles.GetShort(name) if ok { name = file.OName } } if i != 0 { o += "," + name } else { o += name } } h.Writer.Header().Set("X-Res", o) } func (h *Header) AddNotice(name string) { h.NoticeList = append(h.NoticeList, p.GetNoticePhrase(name)) } // TODO: Add this to routes which don't use templates. E.g. Json APIs. type HeaderLite struct { Site *site Settings SettingMap Hooks *HookTable ExtData ExtData } type PageWidgets struct { LeftSidebar template.HTML RightSidebar template.HTML } // TODO: Add a ExtDataHolder interface with methods for manipulating the contents? // ? - Could we use a sync.Map instead? type ExtData struct { Items map[string]interface{} // Key: pluginname sync.RWMutex } type Page struct { *Header ItemList []interface{} Something interface{} } type SimplePage struct { *Header } type ErrorPage struct { *Header Message string } type Paginator struct { PageList []int Page int LastPage int } type PaginatorMod struct { Params template.URL PageList []int Page int LastPage int } type CustomPagePage struct { *Header Page *CustomPage } type TopicCEditPost struct { ID int Source string Ref string } type TopicCAttachItem struct { ID int ImgSrc string Path string FullPath string } type TopicCPollInput struct { Index int Place string } type TopicPage struct { *Header ItemList []*ReplyUser Topic TopicUser Forum *Forum Poll *Poll Paginator } type TopicListSort struct { SortBy string // lastupdate, mostviewed, mostviewedtoday, mostviewedthisweek, mostviewedthismonth Ascending bool } type QuickTools struct { CanDelete bool CanLock bool CanMove bool } type TopicListPage struct { *Header TopicList []TopicsRowMut ForumList []Forum DefaultForum int Sort TopicListSort SelectedFids []int QuickTools Paginator } type ForumPage struct { *Header ItemList []TopicsRowMut Forum *Forum CanLock bool CanMove bool Paginator } type ForumsPage struct { *Header ItemList []Forum } type ProfilePage struct { *Header ItemList []*ReplyUser ProfileOwner User CurrentScore int NextScore int Blocked bool CanMessage bool CanComment bool ShowComments bool } type CreateTopicPage struct { *Header ItemList []Forum FID int } type IPSearchPage struct { *Header ItemList map[int]*User IP string } // WIP: Optional anti-bot methods type RegisterVerifyImageGridImage struct { Src string } type RegisterVerifyImageGrid struct { Question string Items []RegisterVerifyImageGridImage } type RegisterVerify struct { NoScript bool Image *RegisterVerifyImageGrid } type RegisterPage struct { *Header RequireEmail bool Token string Verify []RegisterVerify } type Account struct { *Header HTMLID string TmplName string Inner nobreak } type EmailListPage struct { *Header ItemList []Email } type AccountLoginsPage struct { *Header ItemList []LoginLogItem Paginator } type AccountBlocksPage struct { *Header Users []*User Paginator } type AccountPrivacyPage struct { *Header ProfileComments int ReceiveConvos int EnableEmbeds bool } type AccountDashPage struct { *Header MFASetup bool CurrentScore int NextScore int NextLevel int Percentage int } type LevelListItem struct { Level int Score int Status string Percentage int // 0 to 200 to fit with the CSS logic } type LevelListPage struct { *Header Levels []LevelListItem } type ResetPage struct { *Header UID int Token string MFA bool } type ConvoListRow struct { *ConversationExtra ShortUsers []*User OneOnOne bool } type ConvoListPage struct { *Header Convos []ConvoListRow Paginator } type ConvoViewRow struct { *ConversationPost User *User ClassName string ContentLines int CanModify bool } type ConvoViewPage struct { *Header Convo *Conversation Posts []ConvoViewRow Users []*User CanReply bool Paginator } type ConvoCreatePage struct { *Header RecpName string } /* WIP for dyntmpl */ type Panel struct { *BasePanelPage HTMLID string ClassNames string TmplName string Inner nobreak } type PanelAnalytics struct { *BasePanelPage FormAction string TmplName string Inner nobreak } type PanelAnalyticsStd struct { Graph PanelTimeGraph ViewItems []PanelAnalyticsItem TimeRange string Unit string TimeType string } type PanelAnalyticsStdUnit struct { Graph PanelTimeGraph ViewItems []PanelAnalyticsItemUnit TimeRange string Unit string TimeType string } type PanelAnalyticsActiveMemory struct { Graph PanelTimeGraph ViewItems []PanelAnalyticsItemUnit TimeRange string Unit string TimeType string MemType int } type PanelAnalyticsPerf struct { Graph PanelTimeGraph ViewItems []PanelAnalyticsItemUnit TimeRange string Unit string TimeType string PerfType int } type PanelStats struct { Users int Groups int Forums int Pages int Settings int WordFilters int Themes int Reports int } type BasePanelPage struct { *Header Stats PanelStats Zone string ReportForumID int DebugAdmin bool } type PanelPage struct { *BasePanelPage ItemList []interface{} Something interface{} } type GridElement struct { ID string Href string Body string Order int // For future use Class string Background string TextColour string Note string } type DashGrids struct { Grid1 []GridElement Grid2 []GridElement } type PanelDashboardPage struct { *BasePanelPage Grids DashGrids } type PanelSetting struct { *Setting FriendlyName string } type PanelSettingPage struct { *BasePanelPage ItemList []OptionLabel Setting *PanelSetting } type PanelUserEditPage struct { *BasePanelPage Groups []*Group User *User ShowEmail bool } type PanelCustomPagesPage struct { *BasePanelPage ItemList []*CustomPage Paginator } type PanelCustomPageEditPage struct { *BasePanelPage Page *CustomPage } /*type PanelTimeGraph struct { Series []int64 // The counts on the left Labels []int64 // unixtimes for the bottom, gets converted into 1:00, 2:00, etc. with JS }*/ type PanelTimeGraph struct { Series [][]int64 // The counts on the left Labels []int64 // unixtimes for the bottom, gets converted into 1:00, 2:00, etc. with JS Legends []string } type PanelAnalyticsItem struct { Time int64 Count int64 } type PanelAnalyticsItemUnit struct { Time int64 Count int64 Unit string } type PanelAnalyticsPage struct { *BasePanelPage Graph PanelTimeGraph ViewItems []PanelAnalyticsItem TimeRange string Unit string TimeType string } type PanelAnalyticsRoutesItem struct { Route string Count int } type PanelAnalyticsRoutesPage struct { *BasePanelPage ItemList []PanelAnalyticsRoutesItem Graph PanelTimeGraph TimeRange string } type PanelAnalyticsRoutesPerfItem struct { Route string Count int Unit string } type PanelAnalyticsRoutesPerfPage struct { *BasePanelPage ItemList []PanelAnalyticsRoutesPerfItem Graph PanelTimeGraph TimeRange string } // TODO: Rename the fields as this structure is being used in a generic way now type PanelAnalyticsAgentsItem struct { Agent string FriendlyAgent string Count int } type PanelAnalyticsAgentsPage struct { *BasePanelPage ItemList []PanelAnalyticsAgentsItem TimeRange string } type PanelAnalyticsReferrersPage struct { *BasePanelPage ItemList []PanelAnalyticsAgentsItem TimeRange string ShowSpam bool } type PanelAnalyticsRoutePage struct { *BasePanelPage Route string Graph PanelTimeGraph ViewItems []PanelAnalyticsItem TimeRange string } type PanelAnalyticsAgentPage struct { *BasePanelPage Agent string FriendlyAgent string Graph PanelTimeGraph TimeRange string } type PanelAnalyticsDuoPage struct { *BasePanelPage ItemList []PanelAnalyticsAgentsItem Graph PanelTimeGraph TimeRange string } type PanelThemesPage struct { *BasePanelPage PrimaryThemes []*Theme VariantThemes []*Theme } type PanelMenuListItem struct { Name string ID int ItemCount int } type PanelMenuListPage struct { *BasePanelPage ItemList []PanelMenuListItem } type PanelWidgetListPage struct { *BasePanelPage Docks map[string][]WidgetEdit BlankWidget WidgetEdit } type PanelMenuPage struct { *BasePanelPage MenuID int ItemList []MenuItem } type PanelMenuItemPage struct { *BasePanelPage Item MenuItem } type PanelUserPageSearch struct { Name string Email string Group int Any bool } type PanelUserPage struct { *BasePanelPage ItemList []*User Groups []*Group Search PanelUserPageSearch PaginatorMod } type PanelGroupPage struct { *BasePanelPage ItemList []GroupAdmin Paginator } type PanelEditGroupPage struct { *BasePanelPage ID int Name string Tag string Rank string DisableRank bool } type GroupForumPermPreset struct { Group *Group Preset string DefaultPreset bool } type PanelEditForumPage struct { *BasePanelPage ID int Name string Desc string Active bool Preset string Groups []GroupForumPermPreset Actions []*ForumActionAction } type ForumActionAction struct { *ForumAction ActionName string } type NameLangToggle struct { Name string LangStr string Toggle bool } type PanelEditForumGroupPage struct { *BasePanelPage ForumID int GroupID int Name string Desc string Active bool Preset string Perms []NameLangToggle } type PanelEditGroupPermsPage struct { *BasePanelPage ID int Name string LocalPerms []NameLangToggle GlobalPerms []NameLangToggle ModPerms []NameLangToggle } type GroupPromotionExtend struct { *GroupPromotion FromGroup *Group ToGroup *Group } type PanelEditGroupPromotionsPage struct { *BasePanelPage ID int Name string Promotions []*GroupPromotionExtend Groups []*Group } type BackupItem struct { SQLURL string // TODO: Add an easier to parse format here for Gosora to be able to more easily reimport portions of the dump and to strip unnecessary data (e.g. table defs and parsed post data) Timestamp time.Time } type PanelBackupPage struct { *BasePanelPage Backups []BackupItem } type PageLogItem struct { Action template.HTML IP string DoneAt string } type PanelLogsPage struct { *BasePanelPage Logs []PageLogItem Paginator } type PageRegLogItem struct { RegLogItem ParsedReason string } type PanelRegLogsPage struct { *BasePanelPage Logs []PageRegLogItem Paginator } type DebugPageTasks struct { HalfSecond int Second int FifteenMinute int Hour int Day int Shutdown int } type DebugPageCache struct { Topics int Users int Replies int TCap int UCap int RCap int TopicListThaw bool } type DebugPageDatabase struct { Topics int Users int Replies int ProfileReplies int ActivityStream int Likes int Attachments int Polls int LoginLogs int RegLogs int ModLogs int AdminLogs int Views int ViewsAgents int ViewsForums int ViewsLangs int ViewsReferrers int ViewsSystems int PostChunks int TopicChunks int } type DebugPageDisk struct { Static int Attachments int Avatars int Logs int Backups int Git int } type PanelDebugPage struct { *BasePanelPage GoVersion string DBVersion string Uptime string DBConns int DBAdapter string Goroutines int CPUs int HttpConns int Tasks DebugPageTasks MemStats runtime.MemStats Cache DebugPageCache Database DebugPageDatabase Disk DebugPageDisk } type PanelTaskTask struct { Name string Type int // 0 = halfsec, 1 = sec, 2 = fifteenmin, 3 = hour, 4 = shutdown } type PanelTaskType struct { Name string FAvg string } type PanelTaskPage struct { *BasePanelPage Tasks []PanelTaskTask Types []PanelTaskType } type PageSimple struct { Title string Something interface{} } type AreYouSure struct { URL string Message string } // TODO: Write a test for this func DefaultHeader(w http.ResponseWriter, u *User) *Header { return &Header{Site: Site, Theme: Themes[fallbackTheme], CurrentUser: u, Writer: w} } func SimpleDefaultHeader(w http.ResponseWriter) *Header { return &Header{Site: Site, Theme: Themes[fallbackTheme], CurrentUser: &GuestUser, Writer: w} } ================================================ FILE: common/parser.go ================================================ package common import ( "bytes" //"fmt" //"log" "net/url" "path/filepath" "regexp" "strconv" "strings" "unicode/utf8" ) // TODO: Use the template system? // TODO: Somehow localise these? var SpaceGap = []byte(" ") var httpProtBytes = []byte("http://") var DoubleForwardSlash = []byte("//") var InvalidURL = []byte("[Invalid URL]") var InvalidTopic = []byte("[Invalid Topic]") var InvalidProfile = []byte("[Invalid Profile]") var InvalidForum = []byte("[Invalid Forum]") var unknownMedia = []byte("[Unknown Media]") var URLOpen = []byte("") var bytesSinglequote = []byte("'") var bytesGreaterThan = []byte(">") var urlMention = []byte("'class='mention'") var URLClose = []byte("") var videoOpen = []byte("") var audioOpen = []byte("") var imageOpen = []byte("") var attachOpen = []byte("Attachment") var sidParam = []byte("?sid=") var stypeParam = []byte("&stype=") /*var textShortOpen = []byte("View / Download")*/ var textOpen = []byte("
") var textOpen2 = []byte("
") var urlPattern = `(?s)([ {1}])((http|https|ftp|mailto)*)(:{??)\/\/([\.a-zA-Z\/]+)([ {1}])` var urlReg *regexp.Regexp const imageSizeHint = len("") const videoSizeHint = len("") + len("?sid=") + len("&stype=") + 8 const audioSizeHint = len("") + len("?sid=") + len("&stype=") + 8 const mentionSizeHint = len("@") + len("") func init() { urlReg = regexp.MustCompile(urlPattern) } var emojis map[string]string type emojiHolder struct { NoDefault bool `json:"no_defaults"` Emojis []map[string]string `json:"emojis"` } func InitEmoji() error { var emoji emojiHolder err := unmarshalJsonFile("./config/emoji_default.json", &emoji) if err != nil { return err } emojis = make(map[string]string, len(emoji.Emojis)) if !emoji.NoDefault { for _, item := range emoji.Emojis { for ikey, ival := range item { emojis[ikey] = ival } } } emoji = emojiHolder{} err = unmarshalJsonFileIgnore404("./config/emoji.json", &emoji) if err != nil { return err } if emoji.NoDefault { emojis = make(map[string]string) } for _, item := range emoji.Emojis { for ikey, ival := range item { emojis[ikey] = ival } } return nil } // TODO: Write a test for this func shortcodeToUnicode(msg string) string { //re := regexp.MustCompile(":(.):") for shortcode, emoji := range emojis { msg = strings.Replace(msg, shortcode, emoji, -1) } return msg } type TagToAction struct { Suffix string Do func(*TagToAction, bool, int, []rune) (int, string) // func(tagToAction,open,i,runes) (newI, output) Depth int // For use by Do PartialMode bool } // TODO: Write a test for this func tryStepForward(i, step int, runes []rune) (int, bool) { i += step if i < len(runes) { return i, true } return i - step, false } // TODO: Write a test for this func tryStepBackward(i, step int, runes []rune) (int, bool) { if i == 0 { return i, false } return i - 1, true } // TODO: Preparse Markdown and normalize it into HTML? // TODO: Use a string builder func PreparseMessage(msg string) string { // TODO: Kick this check down a level into SanitiseBody? if !utf8.ValidString(msg) { return "" } msg = strings.Replace(msg, "


", "\n\n", -1) msg = strings.Replace(msg, "

", "\n\n", -1) msg = strings.Replace(msg, "

", "", -1) // TODO: Make this looser by moving it to the reverse HTML parser? msg = strings.Replace(msg, "
", "\n\n", -1) msg = strings.Replace(msg, "
", "\n\n", -1) // XHTML style msg = strings.Replace(msg, " ", "", -1) msg = strings.Replace(msg, "\r", "", -1) // Windows artifact //msg = strings.Replace(msg, "\n\n\n\n", "\n\n\n", -1) msg = GetHookTable().Sshook("preparse_preassign", msg) // There are a few useful cases for having spaces, but I'd like to stop the WYSIWYG from inserting random lines here and there msg = SanitiseBody(msg) runes := []rune(msg) msg = "" // TODO: We can maybe reduce the size of this by using an offset? // TODO: Move some of these closures out of this function to make things a little more efficient allowedTags := [][]string{ 'e': {"m"}, 's': {"", "trong", "poiler", "pan"}, 'd': {"el"}, 'u': {""}, 'b': {"", "lockquote"}, 'i': {""}, 'h': {"1", "2", "3"}, //'p': {""}, 'g': {""}, // Quick and dirty fix for Grammarly } buildLitMatch := func(tag string) func(*TagToAction, bool, int, []rune) (int, string) { return func(action *TagToAction, open bool, _ int, _ []rune) (int, string) { if open { action.Depth++ return -1, "<" + tag + ">" } if action.Depth <= 0 { return -1, "" } action.Depth-- return -1, "" } } tagToAction := [][]*TagToAction{ 'e': {{"m", buildLitMatch("em"), 0, false}}, 's': { {"", buildLitMatch("del"), 0, false}, {"trong", buildLitMatch("strong"), 0, false}, {"poiler", buildLitMatch("spoiler"), 0, false}, // Hides the span tags Trumbowyg loves blasting out randomly {"pan", func(act *TagToAction, open bool, i int, runes []rune) (int, string) { if open { act.Depth++ //fmt.Println("skipping attributes") for ; i < len(runes); i++ { if runes[i] == '&' && peekMatch(i, "gt;", runes) { //fmt.Println("found tag exit") return i + 3, " " } } return -1, " " } if act.Depth <= 0 { return -1, " " } act.Depth-- return -1, " " }, 0, true}, }, 'd': {{"el", buildLitMatch("del"), 0, false}}, 'u': {{"", buildLitMatch("u"), 0, false}}, 'b': { {"", buildLitMatch("strong"), 0, false}, {"lockquote", buildLitMatch("blockquote"), 0, false}, }, 'i': {{"", buildLitMatch("em"), 0, false}}, 'h': { {"1", buildLitMatch("h2"), 0, false}, {"2", buildLitMatch("h3"), 0, false}, {"3", buildLitMatch("h4"), 0, false}, }, //'p': {{"", buildLitMatch2("\n\n", ""), 0, false}}, 'g': { {"", func(act *TagToAction, open bool, i int, runes []rune) (int, string) { if open { act.Depth++ //fmt.Println("skipping attributes") for ; i < len(runes); i++ { if runes[i] == '&' && peekMatch(i, "gt;", runes) { //fmt.Println("found tag exit") return i + 3, " " } } return -1, " " } if act.Depth <= 0 { return -1, " " } act.Depth-- return -1, " " }, 0, true}, }, } // TODO: Implement a less literal parser // TODO: Use a string builder // TODO: Implement faster emoji parser for i := 0; i < len(runes); i++ { char := runes[i] // TODO: Make the slashes escapable too in case someone means to use a literaly slash, maybe as an example of how to escape elements? if char == '\\' { if peekMatch(i, "<", runes) { msg += "&" i++ } } else if char == '&' && peekMatch(i, "lt;", runes) { var ok bool i, ok = tryStepForward(i, 4, runes) if !ok { msg += "<" break } char := runes[i] if int(char) >= len(allowedTags) { //fmt.Println("sentinel char out of bounds") msg += "&" i -= 4 continue } var closeTag bool if char == '/' { //fmt.Println("found close tag") i, ok = tryStepForward(i, 1, runes) if !ok { msg += "</" break } char = runes[i] closeTag = true } tags := allowedTags[char] if len(tags) == 0 { //fmt.Println("couldn't find char in allowedTags") msg += "&" if closeTag { //msg += "</" //msg += "&" i -= 5 } else { //msg += "&" i -= 4 } continue } // TODO: Scan through tags and make sure the suffix is present to reduce the number of false positives which hit the loop below //fmt.Printf("tags: %+v\n", tags) newI := -1 var out string toActionList := tagToAction[char] for _, toAction := range toActionList { // TODO: Optimise this, maybe with goto or a function call to avoid scanning the text twice? if (toAction.PartialMode && !closeTag && peekMatch(i, toAction.Suffix, runes)) || peekMatch(i, toAction.Suffix+">", runes) { newI, out = toAction.Do(toAction, !closeTag, i, runes) if newI != -1 { i = newI } else if out != "" { i += len(toAction.Suffix + ">") } break } } if out == "" { msg += "&" if closeTag { i -= 5 } else { i -= 4 } } else if out != " " { msg += out } } else if char == '@' && (i == 0 || runes[i-1] < 33) { // TODO: Handle usernames containing spaces, maybe in the front-end with AJAX // Do not mention-ify ridiculously long things var ok bool i, ok = tryStepForward(i, 1, runes) if !ok { msg += "@" continue } start := i for j := 0; i < len(runes) && j < Config.MaxUsernameLength; j++ { cchar := runes[i] if cchar < 33 { break } i++ } username := string(runes[start:i]) if username == "" { msg += "@" i = start - 1 continue } user, err := Users.GetByName(username) if err != nil { if err != ErrNoRows { LogError(err) } msg += "@" i = start - 1 continue } msg += "@" + strconv.Itoa(user.ID) i-- } else { msg += string(char) } } for _, actionList := range tagToAction { for _, toAction := range actionList { if toAction.Depth > 0 { for ; toAction.Depth > 0; toAction.Depth-- { _, out := toAction.Do(toAction, false, len(runes), runes) if out != "" { msg += out } } } } } return strings.TrimSpace(shortcodeToUnicode(msg)) } // TODO: Test this // TODO: Use this elsewhere in the parser? func peek(cur, skip int, runes []rune) rune { if (cur + skip) < len(runes) { return runes[cur+skip] } return 0 // null byte } // TODO: Test this func peekMatch(cur int, phrase string, runes []rune) bool { if cur+len(phrase) > len(runes) { return false } for i, char := range phrase { if cur+i+1 >= len(runes) { return false } if runes[cur+i+1] != char { return false } } return true } // ! Not concurrency safe func AddHashLinkType(prefix string, h func(*strings.Builder, string, *int)) { // There can only be one hash link type starting with a specific character at the moment hashType := hashLinkTypes[prefix[0]] if hashType != "" { return } hashLinkMap[prefix] = h hashLinkTypes[prefix[0]] = prefix } func WriteURL(sb *strings.Builder, url, label string) { sb.Write(URLOpen) sb.WriteString(url) sb.Write(URLOpen2) sb.WriteString(label) sb.Write(URLClose) } var hashLinkTypes = []string{'t': "tid-", 'r': "rid-", 'f': "fid-"} var hashLinkMap = map[string]func(*strings.Builder, string, *int){ "tid-": func(sb *strings.Builder, msg string, i *int) { tid, intLen := CoerceIntString(msg[*i:]) *i += intLen topic, err := Topics.Get(tid) if err != nil || !Forums.Exists(topic.ParentID) { sb.Write(InvalidTopic) return } WriteURL(sb, BuildTopicURL("", tid), "#tid-"+strconv.Itoa(tid)) }, "rid-": func(sb *strings.Builder, msg string, i *int) { rid, intLen := CoerceIntString(msg[*i:]) *i += intLen topic, err := TopicByReplyID(rid) if err != nil || !Forums.Exists(topic.ParentID) { sb.Write(InvalidTopic) return } // TODO: Send the user to the right page and post not just the right topic? WriteURL(sb, BuildTopicURL("", topic.ID), "#rid-"+strconv.Itoa(rid)) }, "fid-": func(sb *strings.Builder, msg string, i *int) { fid, intLen := CoerceIntString(msg[*i:]) *i += intLen if !Forums.Exists(fid) { sb.Write(InvalidForum) return } WriteURL(sb, BuildForumURL("", fid), "#fid-"+strconv.Itoa(fid)) }, // TODO: Forum Shortcode Link } // TODO: Pack multiple bit flags into an integer instead of using a struct? var DefaultParseSettings = &ParseSettings{} type ParseSettings struct { NoEmbed bool } func (ps *ParseSettings) CopyPtr() *ParseSettings { n := &ParseSettings{} *n = *ps return n } func ParseMessage(msg string, sectionID int, sectionType string, settings *ParseSettings, user *User) string { msg, _ = ParseMessage2(msg, sectionID, sectionType, settings, user) return msg } var litRepPrefix = []byte{':', ';'} //var litRep = [][]byte{':':{')','(','D','O','o','P','p'},';':{')'}} var litRep = [][]string{':': {')': "😀", '(': "😞", 'D': "😃", 'O': "😲", 'o': "😲", 'P': "😛", 'p': "😛"}, ';': {')': "😉"}} // TODO: Write a test for this // TODO: We need a lot more hooks here. E.g. To add custom media types and handlers. // TODO: Use templates to reduce the amount of boilerplate? func ParseMessage2(msg string, sectionID int, sectionType string, settings *ParseSettings, user *User) (string, bool) { if settings == nil { settings = DefaultParseSettings } if user == nil { user = &GuestUser } // TODO: Word boundary detection for these to avoid mangling code /*rep := func(find, replace string) { msg = strings.Replace(msg, find, replace, -1) } rep(":)", "😀") rep(":(", "😞") rep(":D", "😃") rep(":P", "😛") rep(":O", "😲") rep(":p", "😛") rep(":o", "😲") rep(";)", "😉")*/ // Word filter list. E.g. Swear words and other things the admins don't like filters, err := WordFilters.GetAll() if err != nil { LogError(err) return "", false } for _, f := range filters { msg = strings.Replace(msg, f.Find, f.Replace, -1) } if len(msg) < 2 { msg = strings.Replace(msg, "\n", "
", -1) msg = GetHookTable().Sshook("parse_assign", msg) return msg, false } // Search for URLs, mentions and hashlinks in the messages... var sb strings.Builder lastItem := 0 i := 0 var externalHead bool //var c bool //fmt.Println("msg:", "'"+msg+"'") for ; len(msg) > i; i++ { //fmt.Printf("msg[%d]: %s\n",i,string(msg[i])) if (i == 0 && (msg[0] > 32)) || (len(msg) > (i+1) && (msg[i] < 33) && (msg[i+1] > 32)) { //fmt.Println("s1") if (i != 0) || msg[i] < 33 { i++ } if len(msg) <= (i + 1) { break } //fmt.Println("s2") ch := msg[i] // Very short literal matcher if len(litRep) > int(ch) { sl := litRep[ch] if sl != nil { i++ ch := msg[i] if len(sl) > int(ch) { val := sl[ch] if val != "" { i-- sb.WriteString(msg[lastItem:i]) i++ sb.WriteString(val) i++ lastItem = i i-- continue } } i-- } //lastItem = i //i-- //continue } switch ch { case '#': //fmt.Println("msg[i+1]:", msg[i+1]) //fmt.Println("string(msg[i+1]):", string(msg[i+1])) hashType := hashLinkTypes[msg[i+1]] if hashType == "" { //fmt.Println("uh1") sb.WriteString(msg[lastItem:i]) i++ lastItem = i continue } //fmt.Println("hashType:", hashType) if len(msg) <= (i + len(hashType) + 1) { sb.WriteString(msg[lastItem:i]) lastItem = i continue } if msg[i+1:i+len(hashType)+1] != hashType { continue } //fmt.Println("msg[lastItem:i]:", msg[lastItem:i]) sb.WriteString(msg[lastItem:i]) i += len(hashType) + 1 hashLinkMap[hashType](&sb, msg, &i) lastItem = i i-- case '@': sb.WriteString(msg[lastItem:i]) i++ start := i uid, intLen := CoerceIntString(msg[start:]) i += intLen var menUser *User if uid != 0 && user.ID == uid { menUser = user } else { menUser = Users.Getn(uid) if menUser == nil { sb.Write(InvalidProfile) lastItem = i i-- continue } } sb.Grow(mentionSizeHint + len(menUser.Link) + len(menUser.Name)) sb.Write(URLOpen) sb.WriteString(menUser.Link) sb.Write(urlMention) sb.Write(bytesGreaterThan) sb.WriteByte('@') sb.WriteString(menUser.Name) sb.Write(URLClose) lastItem = i i-- case 'h', 'f', 'g', '/', 'i': //fmt.Println("s3") fch := msg[i+1] if msg[i] == 'h' && fch == 't' && len(msg) > i+5 && msg[i+2] == 't' && msg[i+3] == 'p' { if msg[i+4] == 's' && msg[i+5] == ':' && len(msg) > i+6 && msg[i+6] == '/' { // Do nothing } else if msg[i+4] == ':' && msg[i+5] == '/' { // Do nothing } else { continue } } else if len(msg) > i+4 { if fch == 't' && msg[i+2] == 'p' && msg[i+3] == ':' && msg[i+4] == '/' && msg[i] == 'f' { // Do nothing } else if fch == 'i' && msg[i+2] == 't' && msg[i+3] == ':' && msg[i+4] == '/' && msg[i] == 'g' { // Do nothing } else if msg[i] == 'i' && fch == 'p' && msg[i+2] == 'f' && msg[i+3] == 's' { // Do nothing } else if msg[i] == 'i' && fch == 'p' && msg[i+2] == 'n' && msg[i+3] == 's' { // Do nothing } else if fch == '/' && msg[i] == '/' { // Do nothing } else { continue } } else if fch == '/' && msg[i] == '/' { // Do nothing } else { continue } if !user.Perms.AutoLink { continue } //fmt.Println("p1:",i) sb.WriteString(msg[lastItem:i]) urlLen, ok := PartialURLStringLen(msg[i:]) if len(msg) < i+urlLen { //fmt.Println("o1") if urlLen == 2 { sb.Write(DoubleForwardSlash) } else { sb.Write(InvalidURL) } i += len(msg) - 1 lastItem = i break } if urlLen == 2 { sb.Write(DoubleForwardSlash) i += urlLen lastItem = i i-- continue } //fmt.Println("msg[i:i+urlLen]:", "'"+msg[i:i+urlLen]+"'") if !ok { //fmt.Printf("o2: i = %d; i+urlLen = %d\n",i,i+urlLen) sb.Write(InvalidURL) i += urlLen lastItem = i i-- continue } media, ok := parseMediaString(msg[i:i+urlLen], settings) if !ok { //fmt.Println("o3") sb.Write(InvalidURL) i += urlLen lastItem = i continue } //fmt.Println("p2") addImage := func(url string) { sb.Grow(imageSizeHint + len(url) + len(url)) sb.Write(imageOpen) sb.WriteString(url) sb.Write(imageOpen2) sb.WriteString(url) sb.Write(imageClose) i += urlLen lastItem = i } // TODO: Reduce the amount of code duplication // TODO: Avoid allocating a string for media.Type? switch media.Type { case AImage: addImage(media.URL + "?sid=" + strconv.Itoa(sectionID) + "&stype=" + sectionType) continue case AVideo: sb.Grow(videoSizeHint + (len(media.URL) + len(sectionType)*2)) sb.Write(videoOpen) sb.WriteString(media.URL) sb.Write(sidParam) sb.WriteString(strconv.Itoa(sectionID)) sb.Write(stypeParam) sb.WriteString(sectionType) sb.Write(videoOpen2) sb.WriteString(media.URL) sb.Write(sidParam) sb.WriteString(strconv.Itoa(sectionID)) sb.Write(stypeParam) sb.WriteString(sectionType) sb.Write(videoClose) i += urlLen lastItem = i continue case AAudio: sb.Grow(audioSizeHint + (len(media.URL) + len(sectionType)*2)) sb.Write(audioOpen) sb.WriteString(media.URL) sb.Write(sidParam) sb.WriteString(strconv.Itoa(sectionID)) sb.Write(stypeParam) sb.WriteString(sectionType) sb.Write(audioOpen2) sb.WriteString(media.URL) sb.Write(sidParam) sb.WriteString(strconv.Itoa(sectionID)) sb.Write(stypeParam) sb.WriteString(sectionType) sb.Write(audioClose) i += urlLen lastItem = i continue case EImage: addImage(media.URL) continue case AText: /*sb.Write(textOpen) sb.WriteString(media.URL) sb.Write(sidParam) sid := strconv.Itoa(sectionID) sb.WriteString(sid) sb.Write(stypeParam) sb.WriteString(sectionType) sb.Write(textOpen2) sb.WriteString(media.URL) sb.Write(sidParam) sb.WriteString(sid) sb.Write(stypeParam) sb.WriteString(sectionType) sb.Write(textClose) i += urlLen lastItem = i continue*/ sb.Write(textOpen) sb.WriteString(media.URL) sb.Write(textOpen2) sb.WriteString(media.URL) sb.Write(sidParam) sid := strconv.Itoa(sectionID) sb.WriteString(sid) sb.Write(stypeParam) sb.WriteString(sectionType) sb.Write(textOpen3) sb.WriteString(media.URL) sb.Write(sidParam) sb.WriteString(sid) sb.Write(stypeParam) sb.WriteString(sectionType) sb.Write(textClose) i += urlLen lastItem = i continue case AOther: sb.Write(attachOpen) sb.WriteString(media.URL) sb.Write(sidParam) sb.WriteString(strconv.Itoa(sectionID)) sb.Write(stypeParam) sb.WriteString(sectionType) sb.Write(attachClose) i += urlLen lastItem = i continue case ERaw: sb.WriteString(media.Body) i += urlLen lastItem = i continue case ERawExternal: sb.WriteString(media.Body) i += urlLen lastItem = i externalHead = true continue case ENone: // Do nothing // TODO: Add support for media plugins default: sb.Write(unknownMedia) i += urlLen continue } //fmt.Println("p3") // TODO: Add support for rel="ugc" sb.Grow(len(URLOpen) + (len(msg[i:i+urlLen]) * 2) + len(URLOpen2) + len(URLClose)) if media.Trusted { sb.Write(URLOpen) } else { sb.Write(URLOpenUser) } sb.WriteString(media.URL) sb.Write(URLOpen2) sb.WriteString(media.FURL) sb.Write(URLClose) i += urlLen lastItem = i i-- } } } if lastItem != i && sb.Len() != 0 { /*calclen := len(msg) if calclen <= lastItem { calclen = lastItem }*/ //if i == len(msg) { sb.WriteString(msg[lastItem:]) /*} else { sb.WriteString(msg[lastItem:calclen]) }*/ } if sb.Len() != 0 { msg = sb.String() //fmt.Println("sb.String():", "'"+sb.String()+"'") } msg = strings.Replace(msg, "\n", "
", -1) msg = GetHookTable().Sshook("parse_assign", msg) return msg, externalHead } // 6, 7, 8, 6, 2, 7 // ftp://, http://, https://, git://, ipfs://, ipns://, //, mailto: (not a URL, just here for length comparison purposes) // TODO: Write a test for this func validateURLString(d string) bool { i := 0 if len(d) >= 6 { if d[0:6] == "ftp://" || d[0:6] == "git://" { i = 6 } else if len(d) >= 7 && (d[0:7] == "http://" || d[0:7] == "ipfs://" || d[0:7] == "ipns://") { i = 7 } else if len(d) >= 8 && d[0:8] == "https://" { i = 8 } } else if len(d) >= 2 && d[0] == '/' && d[1] == '/' { i = 2 } // ? - There should only be one : and that's only if the URL is on a non-standard port. Same for ?s. for ; len(d) > i; i++ { ch := d[i] if ch != '\\' && ch != '_' && ch != '?' && ch != '&' && ch != '=' && ch != '@' && ch != '#' && ch != ']' && !(ch > 44 && ch < 60) && !(ch > 64 && ch < 92) && !(ch > 96 && ch < 123) { // 57 is 9, 58 is :, 59 is ;, 90 is Z, 91 is [ return false } } return true } // TODO: Write a test for this func validatedURLBytes(data []byte) (url []byte) { datalen := len(data) i := 0 if datalen >= 6 { if bytes.Equal(data[0:6], []byte("ftp://")) || bytes.Equal(data[0:6], []byte("git://")) { i = 6 } else if datalen >= 7 && bytes.Equal(data[0:7], httpProtBytes) { i = 7 } else if datalen >= 8 && bytes.Equal(data[0:8], []byte("https://")) { i = 8 } } else if datalen >= 2 && data[0] == '/' && data[1] == '/' { i = 2 } // ? - There should only be one : and that's only if the URL is on a non-standard port. Same for ?s. for ; datalen > i; i++ { ch := data[i] if ch != '\\' && ch != '_' && ch != '?' && ch != '&' && ch != '=' && ch != '@' && ch != '#' && ch != ']' && !(ch > 44 && ch < 60) && !(ch > 64 && ch < 92) && !(ch > 96 && ch < 123) { // 57 is 9, 58 is :, 59 is ;, 90 is Z, 91 is [ return InvalidURL } } url = append(url, data...) return url } // TODO: Write a test for this func PartialURLString(d string) (url []byte) { i := 0 end := len(d) - 1 if len(d) >= 6 { if d[0:6] == "ftp://" || d[0:6] == "git://" { i = 6 } else if len(d) >= 7 && (d[0:7] == "http://" || d[0:7] == "ipfs://" || d[0:7] == "ipns://") { i = 7 } else if len(d) >= 8 && d[0:8] == "https://" { i = 8 } } else if len(d) >= 2 && d[0] == '/' && d[1] == '/' { i = 2 } // ? - There should only be one : and that's only if the URL is on a non-standard port. Same for ?s. for ; end >= i; i++ { ch := d[i] if ch != '\\' && ch != '_' && ch != '?' && ch != '&' && ch != '=' && ch != '@' && ch != '#' && ch != ']' && !(ch > 44 && ch < 60) && !(ch > 64 && ch < 92) && !(ch > 96 && ch < 123) { // 57 is 9, 58 is :, 59 is ;, 90 is Z, 91 is [ end = i } } url = append(url, []byte(d[0:end])...) return url } // TODO: Write a test for this // TODO: Handle the host bits differently from the paths... func PartialURLStringLen(d string) (int, bool) { i := 0 if len(d) >= 6 { //log.Print(string(d[0:5])) if d[0:6] == "ftp://" || d[0:6] == "git://" { i = 6 } else if len(d) >= 7 && (d[0:7] == "http://" || d[0:7] == "ipfs://" || d[0:7] == "ipns://") { i = 7 } else if len(d) >= 8 && d[0:8] == "https://" { i = 8 } } else if len(d) >= 2 && d[0] == '/' && d[1] == '/' { i = 2 } //fmt.Println("Data Length: ",len(d)) if len(d) < i { //fmt.Println("e1:",i) return i + 1, false } // ? - There should only be one : and that's only if the URL is on a non-standard port. Same for ?s. f := i //fmt.Println("f:",f) for ; len(d) > i; i++ { ch := d[i] //char if ch < 33 { // space and invisibles //fmt.Println("e2:",i) return i, i != f } else if ch != '\\' && ch != '_' && ch != '?' && ch != '&' && ch != '=' && ch != '@' && ch != '#' && ch != ']' && !(ch > 44 && ch < 60) && !(ch > 64 && ch < 92) && !(ch > 96 && ch < 123) { // 57 is 9, 58 is :, 59 is ;, 90 is Z, 91 is [ //log.Print("Bad Character: ", ch) //fmt.Println("e3") return i, false } } //fmt.Println("e4:", i) /*if data[i-1] < 33 { return i-1, i != f }*/ //fmt.Println("e5") return i, i != f } // TODO: Write a test for this // TODO: Get this to support IPv6 hosts, this isn't currently done as this is used in the bbcode plugin where it thinks the [ is a IPv6 host func PartialURLStringLen2(d string) int { i := 0 if len(d) >= 6 { //log.Print(string(d[0:5])) if d[0:6] == "ftp://" || d[0:6] == "git://" { i = 6 } else if len(d) >= 7 && (d[0:7] == "http://" || d[0:7] == "ipfs://" || d[0:7] == "ipns://") { i = 7 } else if len(d) >= 8 && d[0:8] == "https://" { i = 8 } } else if len(d) >= 2 && d[0] == '/' && d[1] == '/' { i = 2 } // ? - There should only be one : and that's only if the URL is on a non-standard port. Same for ?s. for ; len(d) > i; i++ { ch := d[i] if ch != '\\' && ch != '_' && ch != '?' && ch != '&' && ch != '=' && ch != '@' && ch != '#' && ch != ']' && !(ch > 44 && ch < 60) && !(ch > 64 && ch < 91) && !(ch > 96 && ch < 123) { // 57 is 9, 58 is :, 59 is ;, 90 is Z, 91 is [ //log.Print("Bad Character: ", ch) return i } } //log.Print("Data Length: ",len(d)) return len(d) } type MediaEmbed struct { //Type string //image Type int URL string FURL string Body string Trusted bool // samesite urls } const ( ENone = iota ERaw ERawExternal EImage AImage AVideo AAudio AText AOther ) var LastEmbedID = AOther // TODO: Write a test for this func parseMediaString(data string, settings *ParseSettings) (media MediaEmbed, ok bool) { if !validateURLString(data) { return media, false } uurl, err := url.Parse(data) if err != nil { return media, false } host := uurl.Hostname() scheme := uurl.Scheme if scheme == "ipfs" { media.FURL = data media.URL = media.FURL return media, true } port := uurl.Port() query, err := url.ParseQuery(uurl.RawQuery) if err != nil { return media, false } //fmt.Println("host:", host) //log.Print("Site.URL:",Site.URL) samesite := (host == "localhost" || host == "127.0.0.1" || host == "::1" || host == Site.URL) && scheme != "ipns" if samesite { host = strings.Split(Site.URL, ":")[0] // ?- Test this as I'm not sure it'll do what it should. If someone's running SSL on port 80 or non-SSL on port 443 then... Well... They're in far worse trouble than this... port = Site.Port if Config.SslSchema { scheme = "https" } } if scheme != "" { scheme += ":" } media.Trusted = samesite path := uurl.EscapedPath() //fmt.Println("path:", path) pathFrags := strings.Split(path, "/") if len(pathFrags) >= 2 { if samesite && pathFrags[1] == "attachs" && (scheme == "http:" || scheme == "https:") { var sport string // ? - Assumes the sysadmin hasn't mixed up the two standard ports if port != "443" && port != "80" && port != "" { sport = ":" + port } media.URL = scheme + "//" + host + sport + path ext := strings.TrimPrefix(filepath.Ext(path), ".") if len(ext) == 0 { // TODO: Write a unit test for this return media, false } switch { case ImageFileExts.Contains(ext): media.Type = AImage case WebVideoFileExts.Contains(ext): media.Type = AVideo case WebAudioFileExts.Contains(ext): media.Type = AAudio case TextFileExts.Contains(ext): media.Type = AText default: media.Type = AOther } return media, true } } //fmt.Printf("settings.NoEmbed: %+v\n", settings.NoEmbed) //settings.NoEmbed = false if !settings.NoEmbed { // ? - I don't think this hostname will hit every YT domain // TODO: Make this a more customisable handler rather than hard-coding it in here ytInvalid := func(v string) bool { for _, ch := range v { if !((ch > 47 && ch < 58) || (ch > 64 && ch < 91) || (ch > 96 && ch < 123) || ch == '-' || ch == '_') { var sport string if port != "443" && port != "80" && port != "" { sport = ":" + port } var q string if len(uurl.RawQuery) > 0 { q = "?" + uurl.RawQuery } var frag string if len(uurl.Fragment) > 0 { frag = "#" + uurl.Fragment } media.FURL = host + sport + path + q + frag media.URL = scheme + "//" + media.FURL //fmt.Printf("ytInvalid true: %+v\n",v) return true } } return false } ytInvalid2 := func(t string) bool { for _, ch := range t { if !((ch > 47 && ch < 58) || ch == 'h' || ch == 'm' || ch == 's') { //fmt.Printf("ytInvalid2 true: %+v\n",t) return true } } return false } if strings.HasSuffix(host, ".youtube.com") && path == "/watch" { video, ok := query["v"] if ok && len(video) >= 1 && video[0] != "" { v := video[0] if ytInvalid(v) { return media, true } var t, t2 string tt, ok := query["t"] if ok && len(tt) >= 1 { t, t2 = tt[0], tt[0] } media.Type = ERawExternal if t != "" && !ytInvalid2(t) { s, m, h := parseDuration(t2) calc := s + (m * 60) + (h * 60 * 60) if calc > 0 { t = "&t=" + t t2 = "?start=" + strconv.Itoa(calc) } else { t, t2 = "", "" } } l := "https://" + host + path + "?v=" + v + t media.Body = "" return media, true } } else if host == "youtu.be" { v := strings.TrimPrefix(path, "/") if ytInvalid(v) { return media, true } l := "https://youtu.be/" + v media.Type = ERawExternal media.Body = "" return media, true } else if strings.HasPrefix(host, "www.nicovideo.jp") && strings.HasPrefix(path, "/watch/sm") { vid, err := strconv.ParseInt(strings.TrimPrefix(path, "/watch/sm"), 10, 64) if err == nil { var sport string if port != "443" && port != "80" && port != "" { sport = ":" + port } media.Type = ERawExternal sm := strconv.FormatInt(vid, 10) l := "https://" + host + sport + path media.Body = "" return media, true } } if lastFrag := pathFrags[len(pathFrags)-1]; lastFrag != "" { // TODO: Write a function for getting the file extension of a string ext := strings.TrimPrefix(filepath.Ext(lastFrag), ".") if len(ext) != 0 { if ImageFileExts.Contains(ext) { media.Type = EImage var sport string if port != "443" && port != "80" && port != "" { sport = ":" + port } media.URL = scheme + "//" + host + sport + path return media, true } // TODO: Support external videos } } } var sport string if port != "443" && port != "80" && port != "" { sport = ":" + port } var q string if len(uurl.RawQuery) > 0 { q = "?" + uurl.RawQuery } var frag string if len(uurl.Fragment) > 0 { frag = "#" + uurl.Fragment } media.FURL = host + sport + path + q + frag media.URL = scheme + "//" + media.FURL return media, true } func parseDuration(dur string) (s, m, h int) { var ibuf []byte for _, ch := range dur { switch { case ch > 47 && ch < 58: ibuf = append(ibuf, byte(ch)) case ch == 'h': h, _ = strconv.Atoi(string(ibuf)) ibuf = ibuf[:0] case ch == 'm': m, _ = strconv.Atoi(string(ibuf)) ibuf = ibuf[:0] case ch == 's': s, _ = strconv.Atoi(string(ibuf)) ibuf = ibuf[:0] } } // Stop accidental uses of timestamps if h == 0 && m == 0 && s < 2 { s = 0 } return s, m, h } // TODO: Write a test for this func CoerceIntString(data string) (res, length int) { if !(data[0] > 47 && data[0] < 58) { return 0, 1 } i := 0 for ; len(data) > i; i++ { if !(data[i] > 47 && data[i] < 58) { conv, err := strconv.Atoi(data[0:i]) if err != nil { return 0, i } return conv, i } } conv, err := strconv.Atoi(data) if err != nil { return 0, i } return conv, i } // TODO: Write tests for this // Make sure we reflect changes to this in the JS port in /public/global.js func Paginate(currentPage, lastPage, maxPages int) (out []int) { diff := lastPage - currentPage pre := 3 if diff < 3 { pre = maxPages - diff } page := currentPage - pre if page < 0 { page = 0 } for len(out) < maxPages && page < lastPage { page++ out = append(out, page) } return out } // TODO: Write tests for this // Make sure we reflect changes to this in the JS port in /public/global.js func PageOffset(count, page, perPage int) (int, int, int) { var offset int lastPage := LastPage(count, perPage) if page > 1 { offset = (perPage * page) - perPage } else if page == -1 { page = lastPage offset = (perPage * page) - perPage } else { page = 1 } // ? - This has been commented out as it created a bug in the user manager where the first user on a page wouldn't be accessible // We don't want the offset to overflow the slices, if everything's in memory /*if offset >= (count - 1) { offset = 0 }*/ return offset, page, lastPage } // TODO: Write tests for this // Make sure we reflect changes to this in the JS port in /public/global.js func LastPage(count, perPage int) int { return (count / perPage) + 1 } ================================================ FILE: common/password_reset.go ================================================ package common import ( "crypto/subtle" "database/sql" "errors" qgen "github.com/Azareal/Gosora/query_gen" ) var PasswordResetter *DefaultPasswordResetter var ErrBadResetToken = errors.New("This reset token has expired.") type DefaultPasswordResetter struct { getTokens *sql.Stmt create *sql.Stmt delete *sql.Stmt } /* type PasswordReset struct { Email string `q:"email"` Uid int `q:"uid"` Validated bool `q:"validated"` Token string `q:"token"` CreatedAt time.Time `q:"createdAt"` } */ func NewDefaultPasswordResetter(acc *qgen.Accumulator) (*DefaultPasswordResetter, error) { pr := "password_resets" return &DefaultPasswordResetter{ getTokens: acc.Select(pr).Columns("token").Where("uid=?").Prepare(), create: acc.Insert(pr).Columns("email,uid,validated,token,createdAt").Fields("?,?,0,?,UTC_TIMESTAMP()").Prepare(), //create: acc.Insert(pr).Cols("email,uid,validated=0,token,createdAt=UTC_TIMESTAMP()").Prep(), delete: acc.Delete(pr).Where("uid=?").Prepare(), //model: acc.Model(w).Cols("email,uid,validated=0,token").Key("uid").CreatedAt("createdAt").Prep(), }, acc.FirstError() } func (r *DefaultPasswordResetter) Create(email string, uid int, token string) error { _, err := r.create.Exec(email, uid, token) return err } func (r *DefaultPasswordResetter) FlushTokens(uid int) error { _, err := r.delete.Exec(uid) return err } func (r *DefaultPasswordResetter) ValidateToken(uid int, token string) error { rows, err := r.getTokens.Query(uid) if err != nil { return err } defer rows.Close() success := false for rows.Next() { var rtoken string if err := rows.Scan(&rtoken); err != nil { return err } if subtle.ConstantTimeCompare([]byte(token), []byte(rtoken)) == 1 { success = true } } if err = rows.Err(); err != nil { return err } if !success { return ErrBadResetToken } return nil } ================================================ FILE: common/permissions.go ================================================ package common import ( "encoding/json" "log" "github.com/Azareal/Gosora/common/phrases" qgen "github.com/Azareal/Gosora/query_gen" ) // TODO: Refactor the perms system var BlankPerms Perms var GuestPerms Perms // AllPerms is a set of global permissions with everything set to true var AllPerms Perms var AllPluginPerms = make(map[string]bool) // ? - Can we avoid duplicating the items in this list in a bunch of places? var GlobalPermList = []string{ "BanUsers", "ActivateUsers", "EditUser", "EditUserEmail", "EditUserPassword", "EditUserGroup", "EditUserGroupSuperMod", "EditUserGroupAdmin", "EditGroup", "EditGroupLocalPerms", "EditGroupGlobalPerms", "EditGroupSuperMod", "EditGroupAdmin", "ManageForums", "EditSettings", "ManageThemes", "ManagePlugins", "ViewAdminLogs", "ViewIPs", "UploadFiles", "UploadAvatars", "UseConvos", "UseConvosOnlyWithMod", "CreateProfileReply", "AutoEmbed", "AutoLink", } // Permission Structure: ActionComponent[Subcomponent]Flag type Perms struct { // Global Permissions BanUsers bool `json:",omitempty"` ActivateUsers bool `json:",omitempty"` EditUser bool `json:",omitempty"` EditUserEmail bool `json:",omitempty"` EditUserPassword bool `json:",omitempty"` EditUserGroup bool `json:",omitempty"` EditUserGroupSuperMod bool `json:",omitempty"` EditUserGroupAdmin bool `json:",omitempty"` EditGroup bool `json:",omitempty"` EditGroupLocalPerms bool `json:",omitempty"` EditGroupGlobalPerms bool `json:",omitempty"` EditGroupSuperMod bool `json:",omitempty"` EditGroupAdmin bool `json:",omitempty"` ManageForums bool `json:",omitempty"` // This could be local, albeit limited for per-forum managers? EditSettings bool `json:",omitempty"` ManageThemes bool `json:",omitempty"` ManagePlugins bool `json:",omitempty"` ViewAdminLogs bool `json:",omitempty"` ViewIPs bool `json:",omitempty"` // Global non-staff permissions UploadFiles bool `json:",omitempty"` UploadAvatars bool `json:",omitempty"` UseConvos bool `json:",omitempty"` UseConvosOnlyWithMod bool `json:",omitempty"` CreateProfileReply bool `json:",omitempty"` AutoEmbed bool `json:",omitempty"` AutoLink bool `json:",omitempty"` // Forum permissions ViewTopic bool `json:",omitempty"` //ViewOwnTopic bool `json:",omitempty"` LikeItem bool `json:",omitempty"` CreateTopic bool `json:",omitempty"` EditTopic bool `json:",omitempty"` DeleteTopic bool `json:",omitempty"` CreateReply bool `json:",omitempty"` //CreateReplyToOwn bool `json:",omitempty"` EditReply bool `json:",omitempty"` //EditOwnReply bool `json:",omitempty"` DeleteReply bool `json:",omitempty"` //DeleteOwnReply bool `json:",omitempty"` PinTopic bool `json:",omitempty"` CloseTopic bool `json:",omitempty"` //CloseOwnTopic bool `json:",omitempty"` MoveTopic bool `json:",omitempty"` //ExtData map[string]bool `json:",omitempty"` } func init() { BlankPerms = Perms{ //ExtData: make(map[string]bool), } GuestPerms = Perms{ ViewTopic: true, //ExtData: make(map[string]bool), } AllPerms = Perms{ BanUsers: true, ActivateUsers: true, EditUser: true, EditUserEmail: true, EditUserPassword: true, EditUserGroup: true, EditUserGroupSuperMod: true, EditUserGroupAdmin: true, EditGroup: true, EditGroupLocalPerms: true, EditGroupGlobalPerms: true, EditGroupSuperMod: true, EditGroupAdmin: true, ManageForums: true, EditSettings: true, ManageThemes: true, ManagePlugins: true, ViewAdminLogs: true, ViewIPs: true, UploadFiles: true, UploadAvatars: true, UseConvos: true, UseConvosOnlyWithMod: true, CreateProfileReply: true, AutoEmbed: true, AutoLink: true, ViewTopic: true, LikeItem: true, CreateTopic: true, EditTopic: true, DeleteTopic: true, CreateReply: true, EditReply: true, DeleteReply: true, PinTopic: true, CloseTopic: true, MoveTopic: true, //ExtData: make(map[string]bool), } GuestUser.Perms = GuestPerms DebugLogf("Guest Perms: %+v\n", GuestPerms) DebugLogf("All Perms: %+v\n", AllPerms) } func StripInvalidGroupForumPreset(preset string) string { switch preset { case "read_only", "can_post", "can_moderate", "no_access", "default", "custom": return preset } return "" } func StripInvalidPreset(preset string) string { switch preset { case "all", "announce", "members", "staff", "admins", "archive", "custom": return preset } return "" } // TODO: Move this into the phrase system? func PresetToLang(preset string) string { phrases := phrases.GetAllPermPresets() phrase, ok := phrases[preset] if !ok { phrase = phrases["unknown"] } return phrase } // TODO: Is this racey? // TODO: Test this along with the rest of the perms system func RebuildGroupPermissions(g *Group) error { var permstr []byte log.Print("Reloading a group") // TODO: Avoid re-initting this all the time getGroupPerms, e := qgen.Builder.SimpleSelect("users_groups", "permissions", "gid=?", "", "") if e != nil { return e } defer getGroupPerms.Close() e = getGroupPerms.QueryRow(g.ID).Scan(&permstr) if e != nil { return e } tmpPerms := Perms{ //ExtData: make(map[string]bool), } e = json.Unmarshal(permstr, &tmpPerms) if e != nil { return e } g.Perms = tmpPerms return nil } func OverridePerms(p *Perms, status bool) { if status { *p = AllPerms } else { *p = BlankPerms } } // TODO: We need a better way of overriding forum perms rather than setting them one by one func OverrideForumPerms(p *Perms, status bool) { p.ViewTopic = status p.LikeItem = status p.CreateTopic = status p.EditTopic = status p.DeleteTopic = status p.CreateReply = status p.EditReply = status p.DeleteReply = status p.PinTopic = status p.CloseTopic = status p.MoveTopic = status } func RegisterPluginPerm(name string) { AllPluginPerms[name] = true } func DeregisterPluginPerm(name string) { delete(AllPluginPerms, name) } ================================================ FILE: common/phrases/phrases.go ================================================ /* * * Gosora Phrase System * Copyright Azareal 2017 - 2020 * */ package phrases import ( "encoding/json" "errors" "fmt" "io/ioutil" "log" "os" "path/filepath" "strconv" "strings" "sync" "sync/atomic" "time" ) // TODO: Add a phrase store? // TODO: Let the admin edit phrases from inside the Control Panel? How should we persist these? Should we create a copy of the langpack or edit the primaries? Use the changeLangpack mutex for this? // nolint Be quiet megacheck, this *is* used var currentLangPack atomic.Value var langPackCount int // TODO: Use atomics for this // TODO: We'll be implementing the level phrases in the software proper very very soon! type LevelPhrases struct { Level string LevelMax string // ? Add a max level setting? // Override the phrase for individual levels, if the phrases exist Levels []string // index = level } // ! For the sake of thread safety, you must never modify a *LanguagePack directly, but to create a copy of it and overwrite the entry in the sync.Map type LanguagePack struct { Name string IsoCode string ModTime time.Time //LastUpdated string // Should we use a sync map or a struct for these? It would be nice, if we could keep all the phrases consistent. Levels LevelPhrases Perms map[string]string SettingPhrases map[string]string PermPresets map[string]string Accounts map[string]string // TODO: Apply these phrases in the software proper UserAgents map[string]string OperatingSystems map[string]string HumanLanguages map[string]string Errors map[string]string // Temp stand-in ErrorsBytes map[string][]byte NoticePhrases map[string]string PageTitles map[string]string TmplPhrases map[string]string TmplPhrasesPrefixes map[string]map[string]string // [prefix][name]phrase TmplIndicesToPhrases [][][]byte // [tmplID][index]phrase } // TODO: Add the ability to edit language JSON files from the Control Panel and automatically scan the files for changes var langPacks sync.Map // nolint it is used var langTmplIndicesToNames [][]string // [tmplID][index]phraseName func InitPhrases(lang string) error { log.Print("Loading the language packs") err := filepath.Walk("./langs", func(path string, f os.FileInfo, err error) error { if f.IsDir() { return nil } if err != nil { return err } ext := filepath.Ext("/langs/" + path) if ext != ".json" { log.Printf("Found a '%s' in /langs/", ext) return nil } data, err := ioutil.ReadFile(path) if err != nil { return err } var langPack LanguagePack err = json.Unmarshal(data, &langPack) if err != nil { return err } langPack.ModTime = f.ModTime() langPack.ErrorsBytes = make(map[string][]byte) for name, phrase := range langPack.Errors { langPack.ErrorsBytes[name] = []byte(phrase) } // [prefix][name]phrase langPack.TmplPhrasesPrefixes = make(map[string]map[string]string) conMap := make(map[string]string) // Cache phrase strings so we can de-dupe items to reduce memory use. There appear to be some minor improvements with this, although we would need a more thorough check to be sure. for name, phrase := range langPack.TmplPhrases { _, ok := conMap[phrase] if !ok { conMap[phrase] = phrase } cItem := conMap[phrase] prefix := strings.Split(name, ".")[0] _, ok = langPack.TmplPhrasesPrefixes[prefix] if !ok { langPack.TmplPhrasesPrefixes[prefix] = make(map[string]string) } langPack.TmplPhrasesPrefixes[prefix][name] = cItem } // [prefix][name]phrase /*langPack.TmplPhrasesPrefixes = make(map[string]map[string]string) for name, phrase := range langPack.TmplPhrases { prefix := strings.Split(name, ".")[0] _, ok := langPack.TmplPhrasesPrefixes[prefix] if !ok { langPack.TmplPhrasesPrefixes[prefix] = make(map[string]string) } langPack.TmplPhrasesPrefixes[prefix][name] = phrase }*/ langPack.TmplIndicesToPhrases = make([][][]byte, len(langTmplIndicesToNames)) for tmplID, phraseNames := range langTmplIndicesToNames { phraseSet := make([][]byte, len(phraseNames)) for index, phraseName := range phraseNames { phrase, ok := langPack.TmplPhrases[phraseName] if !ok { log.Printf("langPack.TmplPhrases: %+v\n", langPack.TmplPhrases) panic("Couldn't find template phrase '" + phraseName + "'") } phraseSet[index] = []byte(phrase) } langPack.TmplIndicesToPhrases[tmplID] = phraseSet TmplIndexCallback(tmplID, phraseSet) } log.Print("Adding the '" + langPack.Name + "' language pack") langPacks.Store(langPack.Name, &langPack) langPackCount++ return nil }) if err != nil { return err } if langPackCount == 0 { return errors.New("You don't have any language packs") } langPack, ok := langPacks.Load(lang) if !ok { return errors.New("Couldn't find the " + lang + " language pack") } currentLangPack.Store(langPack) return nil } // TODO: Implement this func LoadLangPack(name string) error { _ = name return nil } // TODO: Implement this func SaveLangPack(langPack *LanguagePack) error { _ = langPack return nil } func GetLangPack() *LanguagePack { return currentLangPack.Load().(*LanguagePack) } func GetLevelPhrase(level int) string { levelPhrases := currentLangPack.Load().(*LanguagePack).Levels if len(levelPhrases.Levels) > 0 && level < len(levelPhrases.Levels) { return strings.Replace(levelPhrases.Levels[level], "{0}", strconv.Itoa(level), -1) } return strings.Replace(levelPhrases.Level, "{0}", strconv.Itoa(level), -1) } func GetPermPhrase(name string) string { res, ok := currentLangPack.Load().(*LanguagePack).Perms[name] if !ok { return getPlaceholder("perms", name) } return res } func GetSettingPhrase(name string) string { res, ok := currentLangPack.Load().(*LanguagePack).SettingPhrases[name] if !ok { return getPlaceholder("settings", name) } return res } func GetAllSettingPhrases() map[string]string { return currentLangPack.Load().(*LanguagePack).SettingPhrases } func GetAllPermPresets() map[string]string { return currentLangPack.Load().(*LanguagePack).PermPresets } func GetAccountPhrase(name string) string { res, ok := currentLangPack.Load().(*LanguagePack).Accounts[name] if !ok { return getPlaceholder("account", name) } return res } func GetUserAgentPhrase(name string) (string, bool) { res, ok := currentLangPack.Load().(*LanguagePack).UserAgents[name] if !ok { return "", false } return res, true } func GetOSPhrase(name string) (string, bool) { res, ok := currentLangPack.Load().(*LanguagePack).OperatingSystems[name] if !ok { return "", false } return res, true } func GetHumanLangPhrase(name string) (string, bool) { res, ok := currentLangPack.Load().(*LanguagePack).HumanLanguages[name] if !ok { return getPlaceholder("humanlang", name), false } return res, true } // TODO: Does comma ok work with multi-dimensional maps? func GetErrorPhrase(name string) string { res, ok := currentLangPack.Load().(*LanguagePack).Errors[name] if !ok { return getPlaceholder("error", name) } return res } func GetErrorPhraseBytes(name string) []byte { res, ok := currentLangPack.Load().(*LanguagePack).ErrorsBytes[name] if !ok { return getPlaceholderBytes("error", name) } return res } func GetNoticePhrase(name string) string { res, ok := currentLangPack.Load().(*LanguagePack).NoticePhrases[name] if !ok { return getPlaceholder("notices", name) } return res } func GetTitlePhrase(name string) string { res, ok := currentLangPack.Load().(*LanguagePack).PageTitles[name] if !ok { return getPlaceholder("title", name) } return res } func GetTitlePhrasef(name string, params ...interface{}) string { res, ok := currentLangPack.Load().(*LanguagePack).PageTitles[name] if !ok { return getPlaceholder("title", name) } return fmt.Sprintf(res, params...) } func GetTmplPhrase(name string) string { res, ok := currentLangPack.Load().(*LanguagePack).TmplPhrases[name] if !ok { return getPlaceholder("tmpl", name) } return res } func GetTmplPhrasef(name string, params ...interface{}) string { res, ok := currentLangPack.Load().(*LanguagePack).TmplPhrases[name] if !ok { return getPlaceholder("tmpl", name) } return fmt.Sprintf(res, params...) } func GetTmplPhrases() map[string]string { return currentLangPack.Load().(*LanguagePack).TmplPhrases } func GetTmplPhrasesByPrefix(prefix string) (phrases map[string]string, ok bool) { res, ok := currentLangPack.Load().(*LanguagePack).TmplPhrasesPrefixes[prefix] return res, ok } func getPlaceholder(prefix, suffix string) string { return "{lang." + prefix + "[" + suffix + "]}" } func getPlaceholderBytes(prefix, suffix string) []byte { return []byte("{lang." + prefix + "[" + suffix + "]}") } // ! Please don't mutate *LanguagePack func GetCurrentLangPack() *LanguagePack { return currentLangPack.Load().(*LanguagePack) } // ? - Use runtime reflection for updating phrases? // TODO: Implement these func AddPhrase() { } func UpdatePhrase() { } func DeletePhrase() { } // TODO: Use atomics to store the pointer of the current active langpack? // nolint func ChangeLanguagePack(name string) (exists bool) { pack, ok := langPacks.Load(name) if !ok { return false } currentLangPack.Store(pack) return true } func CurrentLanguagePackName() (name string) { return currentLangPack.Load().(*LanguagePack).Name } func GetLanguagePackByName(name string) (pack *LanguagePack, ok bool) { packInt, ok := langPacks.Load(name) if !ok { return nil, false } return packInt.(*LanguagePack), true } // Template Transpiler Stuff func RegisterTmplPhraseNames(phraseNames []string) (tmplID int) { langTmplIndicesToNames = append(langTmplIndicesToNames, phraseNames) return len(langTmplIndicesToNames) - 1 } func GetTmplPhrasesBytes(tmplID int) [][]byte { return currentLangPack.Load().(*LanguagePack).TmplIndicesToPhrases[tmplID] } // New var indexCallbacks []func([][]byte) func TmplIndexCallback(tmplID int, phraseSet [][]byte) { indexCallbacks[tmplID](phraseSet) } func AddTmplIndexCallback(h func([][]byte)) { indexCallbacks = append(indexCallbacks, h) } ================================================ FILE: common/pluginlangs.go ================================================ package common import ( "encoding/json" "errors" "io/ioutil" "path/filepath" ) var pluginLangs = make(map[string]PluginLang) // For non-native plugins to bind JSON files to. E.g. JS and Lua type PluginMeta struct { UName string Name string Author string URL string Settings string Tag string Skip bool // Skip this folder? Main string // The main file Hooks map[string]string // Hooks mapped to functions } type PluginLang interface { GetName() string GetExts() []string Init() error AddPlugin(meta PluginMeta) (*Plugin, error) //AddHook(name string, handler interface{}) error //RemoveHook(name string, handler interface{}) //RunHook(name string, data interface{}) interface{} //RunVHook(name string data ...interface{}) interface{} } /* var ext = filepath.Ext(pluginFile.Name()) if ext == ".txt" || ext == ".go" { continue } */ func InitPluginLangs() error { for _, pluginLang := range pluginLangs { pluginLang.Init() } pluginList, err := GetPluginFiles() if err != nil { return err } for _, pluginItem := range pluginList { pluginFile, err := ioutil.ReadFile("./extend/" + pluginItem + "/plugin.json") if err != nil { return err } var plugin PluginMeta err = json.Unmarshal(pluginFile, &plugin) if err != nil { return err } if plugin.Skip { continue } e := func(field, name string) error { return errors.New("The " + field + " field must not be blank on plugin '" + name + "'") } if plugin.UName == "" { return e("UName", pluginItem) } if plugin.Name == "" { return e("Name", pluginItem) } if plugin.Author == "" { return e("Author", pluginItem) } if plugin.Main == "" { return errors.New("Couldn't find a main file for plugin '" + pluginItem + "'") } ext := filepath.Ext(plugin.Main) pluginLang, err := ExtToPluginLang(ext) if err != nil { return err } pplugin, err := pluginLang.AddPlugin(plugin) if err != nil { return err } Plugins[plugin.UName] = pplugin } return nil } func GetPluginFiles() (pluginList []string, err error) { pluginFiles, err := ioutil.ReadDir("./extend") if err != nil { return nil, err } for _, pluginFile := range pluginFiles { if !pluginFile.IsDir() { continue } pluginList = append(pluginList, pluginFile.Name()) } return pluginList, nil } func ExtToPluginLang(ext string) (PluginLang, error) { for _, pluginLang := range pluginLangs { for _, registeredExt := range pluginLang.GetExts() { if registeredExt == ext { return pluginLang, nil } } } return nil, errors.New("No plugin lang handlers are capable of handling extension '" + ext + "'") } ================================================ FILE: common/poll.go ================================================ package common import ( "database/sql" qgen "github.com/Azareal/Gosora/query_gen" ) var pollStmts PollStmts type Poll struct { ID int ParentID int ParentTable string Type int // 0: Single choice, 1: Multiple choice, 2: Multiple choice w/ points AntiCheat bool // Apply various mitigations for cheating // GroupPower map[gid]points // The number of points a group can spend in this poll, defaults to 1 Options map[int]string Results map[int]int // map[optionIndex]points QuickOptions []PollOption // TODO: Fix up the template transpiler so we don't need to use this hack anymore VoteCount int } // TODO: Use a transaction for this? // TODO: Add a voters table with castAt / IP data and only populate it when poll anti-cheat is on func (p *Poll) CastVote(optionIndex, uid int, ip string) error { if Config.DisablePollIP || !p.AntiCheat { ip = "" } _, e := pollStmts.addVote.Exec(p.ID, uid, optionIndex, ip) if e != nil { return e } _, e = pollStmts.incVoteCount.Exec(p.ID) if e != nil { return e } _, e = pollStmts.incVoteCountForOption.Exec(optionIndex, p.ID) return e } func (p *Poll) Delete() error { _, e := pollStmts.deletePollVotes.Exec(p.ID) if e != nil { return e } _, e = pollStmts.deletePollOptions.Exec(p.ID) if e != nil { return e } _, e = pollStmts.deletePoll.Exec(p.ID) _ = Polls.GetCache().Remove(p.ID) return e } func (p *Poll) Resultsf(f func(votes int) error) error { rows, e := pollStmts.getResults.Query(p.ID) if e != nil { return e } defer rows.Close() var votes int for rows.Next() { if e := rows.Scan(&votes); e != nil { return e } if e := f(votes); e != nil { return e } } return rows.Err() } func (p *Poll) Copy() Poll { return *p } type PollStmts struct { getResults *sql.Stmt addVote *sql.Stmt incVoteCount *sql.Stmt incVoteCountForOption *sql.Stmt deletePoll *sql.Stmt deletePollOptions *sql.Stmt deletePollVotes *sql.Stmt } func init() { DbInits.Add(func(acc *qgen.Accumulator) error { p := "polls" wh := "pollID=?" pollStmts = PollStmts{ getResults: acc.Select("polls_options").Columns("votes").Where("pollID=?").Orderby("option ASC").Prepare(), addVote: acc.Insert("polls_votes").Columns("pollID,uid,option,castAt,ip").Fields("?,?,?,UTC_TIMESTAMP(),?").Prepare(), incVoteCount: acc.Update(p).Set("votes=votes+1").Where(wh).Prepare(), incVoteCountForOption: acc.Update("polls_options").Set("votes=votes+1").Where("option=? AND pollID=?").Prepare(), deletePoll: acc.Delete(p).Where(wh).Prepare(), deletePollOptions: acc.Delete("polls_options").Where(wh).Prepare(), deletePollVotes: acc.Delete("polls_votes").Where(wh).Prepare(), } return acc.FirstError() }) } ================================================ FILE: common/poll_cache.go ================================================ package common import ( "sync" "sync/atomic" ) // PollCache is an interface which spits out polls from a fast cache rather than the database, whether from memory or from an application like Redis. Polls may not be present in the cache but may be in the database type PollCache interface { Get(id int) (*Poll, error) GetUnsafe(id int) (*Poll, error) BulkGet(ids []int) (list []*Poll) Set(item *Poll) error Add(item *Poll) error AddUnsafe(item *Poll) error Remove(id int) error RemoveUnsafe(id int) error Flush() Length() int SetCapacity(capacity int) GetCapacity() int } // MemoryPollCache stores and pulls polls out of the current process' memory type MemoryPollCache struct { items map[int]*Poll length int64 capacity int sync.RWMutex } // NewMemoryPollCache gives you a new instance of MemoryPollCache func NewMemoryPollCache(capacity int) *MemoryPollCache { return &MemoryPollCache{ items: make(map[int]*Poll), capacity: capacity, } } // Get fetches a poll by ID. Returns ErrNoRows if not present. func (s *MemoryPollCache) Get(id int) (*Poll, error) { s.RLock() item, ok := s.items[id] s.RUnlock() if ok { return item, nil } return item, ErrNoRows } // BulkGet fetches multiple polls by their IDs. Indices without polls will be set to nil, so make sure you check for those, we might want to change this behaviour to make it less confusing. func (s *MemoryPollCache) BulkGet(ids []int) (list []*Poll) { list = make([]*Poll, len(ids)) s.RLock() for i, id := range ids { list[i] = s.items[id] } s.RUnlock() return list } // GetUnsafe fetches a poll by ID. Returns ErrNoRows if not present. THIS METHOD IS NOT THREAD-SAFE. func (s *MemoryPollCache) GetUnsafe(id int) (*Poll, error) { item, ok := s.items[id] if ok { return item, nil } return item, ErrNoRows } // Set overwrites the value of a poll in the cache, whether it's present or not. May return a capacity overflow error. func (s *MemoryPollCache) Set(item *Poll) error { s.Lock() user, ok := s.items[item.ID] if ok { s.Unlock() *user = *item } else if int(s.length) >= s.capacity { s.Unlock() return ErrStoreCapacityOverflow } else { s.items[item.ID] = item s.Unlock() atomic.AddInt64(&s.length, 1) } return nil } // Add adds a poll to the cache, similar to Set, but it's only intended for new items. This method might be deprecated in the near future, use Set. May return a capacity overflow error. // ? Is this redundant if we have Set? Are the efficiency wins worth this? Is this even used? func (s *MemoryPollCache) Add(item *Poll) error { s.Lock() if int(s.length) >= s.capacity { s.Unlock() return ErrStoreCapacityOverflow } s.items[item.ID] = item s.length = int64(len(s.items)) s.Unlock() return nil } // AddUnsafe is the unsafe version of Add. May return a capacity overflow error. THIS METHOD IS NOT THREAD-SAFE. func (s *MemoryPollCache) AddUnsafe(item *Poll) error { if int(s.length) >= s.capacity { return ErrStoreCapacityOverflow } s.items[item.ID] = item s.length = int64(len(s.items)) return nil } // Remove removes a poll from the cache by ID, if they exist. Returns ErrNoRows if no items exist. func (s *MemoryPollCache) Remove(id int) error { s.Lock() _, ok := s.items[id] if !ok { s.Unlock() return ErrNoRows } delete(s.items, id) s.Unlock() atomic.AddInt64(&s.length, -1) return nil } // RemoveUnsafe is the unsafe version of Remove. THIS METHOD IS NOT THREAD-SAFE. func (s *MemoryPollCache) RemoveUnsafe(id int) error { _, ok := s.items[id] if !ok { return ErrNoRows } delete(s.items, id) atomic.AddInt64(&s.length, -1) return nil } // Flush removes all the polls from the cache, useful for tests. func (s *MemoryPollCache) Flush() { m := make(map[int]*Poll) s.Lock() s.items = m s.length = 0 s.Unlock() } // ! Is this concurrent? // Length returns the number of polls in the memory cache func (s *MemoryPollCache) Length() int { return int(s.length) } // SetCapacity sets the maximum number of polls which this cache can hold func (s *MemoryPollCache) SetCapacity(capacity int) { // Ints are moved in a single instruction, so this should be thread-safe s.capacity = capacity } // GetCapacity returns the maximum number of polls this cache can hold func (s *MemoryPollCache) GetCapacity() int { return s.capacity } // NullPollCache is a poll cache to be used when you don't want a cache and just want queries to passthrough to the database type NullPollCache struct { } // NewNullPollCache gives you a new instance of NullPollCache func NewNullPollCache() *NullPollCache { return &NullPollCache{} } // nolint func (s *NullPollCache) Get(id int) (*Poll, error) { return nil, ErrNoRows } func (s *NullPollCache) BulkGet(ids []int) (list []*Poll) { return make([]*Poll, len(ids)) } func (s *NullPollCache) GetUnsafe(id int) (*Poll, error) { return nil, ErrNoRows } func (s *NullPollCache) Set(_ *Poll) error { return nil } func (s *NullPollCache) Add(_ *Poll) error { return nil } func (s *NullPollCache) AddUnsafe(_ *Poll) error { return nil } func (s *NullPollCache) Remove(id int) error { return nil } func (s *NullPollCache) RemoveUnsafe(id int) error { return nil } func (s *NullPollCache) Flush() { } func (s *NullPollCache) Length() int { return 0 } func (s *NullPollCache) SetCapacity(_ int) { } func (s *NullPollCache) GetCapacity() int { return 0 } ================================================ FILE: common/poll_store.go ================================================ package common import ( "database/sql" "encoding/json" "errors" "log" "strconv" qgen "github.com/Azareal/Gosora/query_gen" ) var Polls PollStore type PollOption struct { ID int Value string } type Pollable interface { GetID() int GetTable() string SetPoll(pollID int) error } type PollStore interface { Get(id int) (*Poll, error) Exists(id int) bool ClearIPs() error Create(parent Pollable, pollType int, pollOptions map[int]string) (int, error) Reload(id int) error Count() int SetCache(cache PollCache) GetCache() PollCache } type DefaultPollStore struct { cache PollCache get *sql.Stmt exists *sql.Stmt createPoll *sql.Stmt createPollOption *sql.Stmt delete *sql.Stmt count *sql.Stmt clearIPs *sql.Stmt } func NewDefaultPollStore(cache PollCache) (*DefaultPollStore, error) { acc := qgen.NewAcc() if cache == nil { cache = NewNullPollCache() } // TODO: Add an admin version of registerStmt with more flexibility? p := "polls" return &DefaultPollStore{ cache: cache, get: acc.Select(p).Columns("parentID,parentTable,type,options,votes").Where("pollID=?").Stmt(), exists: acc.Select(p).Columns("pollID").Where("pollID=?").Stmt(), createPoll: acc.Insert(p).Columns("parentID,parentTable,type,options").Fields("?,?,?,?").Prepare(), createPollOption: acc.Insert("polls_options").Columns("pollID,option,votes").Fields("?,?,0").Prepare(), count: acc.Count(p).Prepare(), clearIPs: acc.Update("polls_votes").Set("ip=''").Where("ip!=''").Stmt(), }, acc.FirstError() } func (s *DefaultPollStore) Exists(id int) bool { e := s.exists.QueryRow(id).Scan(&id) if e != nil && e != ErrNoRows { LogError(e) } return e != ErrNoRows } func (s *DefaultPollStore) Get(id int) (*Poll, error) { p, err := s.cache.Get(id) if err == nil { return p, nil } p = &Poll{ID: id} var optionTxt []byte err = s.get.QueryRow(id).Scan(&p.ParentID, &p.ParentTable, &p.Type, &optionTxt, &p.VoteCount) if err != nil { return nil, err } err = json.Unmarshal(optionTxt, &p.Options) if err == nil { p.QuickOptions = s.unpackOptionsMap(p.Options) s.cache.Set(p) } return p, err } // TODO: Optimise the query to avoid preparing it on the spot? Maybe, use knowledge of the most common IN() parameter counts? // TODO: ID of 0 should always error? func (s *DefaultPollStore) BulkGetMap(ids []int) (list map[int]*Poll, err error) { idCount := len(ids) list = make(map[int]*Poll) if idCount == 0 { return list, nil } var stillHere []int sliceList := s.cache.BulkGet(ids) for i, sliceItem := range sliceList { if sliceItem != nil { list[sliceItem.ID] = sliceItem } else { stillHere = append(stillHere, ids[i]) } } ids = stillHere // If every user is in the cache, then return immediately if len(ids) == 0 { return list, nil } idList, q := inqbuild(ids) rows, err := qgen.NewAcc().Select("polls").Columns("pollID,parentID,parentTable,type,options,votes").Where("pollID IN(" + q + ")").Query(idList...) if err != nil { return list, err } for rows.Next() { p := &Poll{ID: 0} var optionTxt []byte err := rows.Scan(&p.ID, &p.ParentID, &p.ParentTable, &p.Type, &optionTxt, &p.VoteCount) if err != nil { return list, err } err = json.Unmarshal(optionTxt, &p.Options) if err != nil { return list, err } p.QuickOptions = s.unpackOptionsMap(p.Options) s.cache.Set(p) list[p.ID] = p } // Did we miss any polls? if idCount > len(list) { var sidList string for _, id := range ids { if _, ok := list[id]; !ok { sidList += strconv.Itoa(id) + "," } } // We probably don't need this, but it might be useful in case of bugs in BulkCascadeGetMap if sidList == "" { // TODO: Bulk log this if Dev.DebugMode { log.Print("This data is sampled later in the BulkCascadeGetMap function, so it might miss the cached IDs") log.Print("idCount", idCount) log.Print("ids", ids) log.Print("list", list) } return list, errors.New("We weren't able to find a poll, but we don't know which one") } sidList = sidList[0 : len(sidList)-1] err = errors.New("Unable to find the polls with the following IDs: " + sidList) } return list, err } func (s *DefaultPollStore) Reload(id int) error { p := &Poll{ID: id} var optionTxt []byte e := s.get.QueryRow(id).Scan(&p.ParentID, &p.ParentTable, &p.Type, &optionTxt, &p.VoteCount) if e != nil { _ = s.cache.Remove(id) return e } e = json.Unmarshal(optionTxt, &p.Options) if e != nil { _ = s.cache.Remove(id) return e } p.QuickOptions = s.unpackOptionsMap(p.Options) _ = s.cache.Set(p) return nil } func (s *DefaultPollStore) unpackOptionsMap(rawOptions map[int]string) []PollOption { opts := make([]PollOption, len(rawOptions)) for id, opt := range rawOptions { opts[id] = PollOption{id, opt} } return opts } func (s *DefaultPollStore) ClearIPs() error { _, e := s.clearIPs.Exec() return e } // TODO: Use a transaction for this func (s *DefaultPollStore) Create(parent Pollable, pollType int, pollOptions map[int]string) (id int, e error) { // TODO: Move the option names into the polls_options table and get rid of this json sludge? pollOptionsTxt, e := json.Marshal(pollOptions) if e != nil { return 0, e } res, e := s.createPoll.Exec(parent.GetID(), parent.GetTable(), pollType, pollOptionsTxt) if e != nil { return 0, e } lastID, e := res.LastInsertId() if e != nil { return 0, e } for i := 0; i < len(pollOptions); i++ { _, e := s.createPollOption.Exec(lastID, i) if e != nil { return 0, e } } id = int(lastID) return id, parent.SetPoll(id) // TODO: Delete the poll (and options) if SetPoll fails } func (s *DefaultPollStore) Count() int { return Count(s.count) } func (s *DefaultPollStore) SetCache(cache PollCache) { s.cache = cache } // TODO: We're temporarily doing this so that you can do ucache != nil in getTopicUser. Refactor it. func (s *DefaultPollStore) GetCache() PollCache { _, ok := s.cache.(*NullPollCache) if ok { return nil } return s.cache } ================================================ FILE: common/profile_reply.go ================================================ package common import ( "database/sql" "html" "strconv" "time" qgen "github.com/Azareal/Gosora/query_gen" ) var profileReplyStmts ProfileReplyStmts type ProfileReply struct { ID int ParentID int Content string CreatedBy int Group int CreatedAt time.Time LastEdit int LastEditBy int ContentLines int IP string } type ProfileReplyStmts struct { edit *sql.Stmt delete *sql.Stmt } func init() { DbInits.Add(func(acc *qgen.Accumulator) error { ur := "users_replies" profileReplyStmts = ProfileReplyStmts{ edit: acc.Update(ur).Set("content=?,parsed_content=?").Where("rid=?").Prepare(), delete: acc.Delete(ur).Where("rid=?").Prepare(), } return acc.FirstError() }) } // Mostly for tests, so we don't wind up with out-of-date profile reply initialisation logic there func BlankProfileReply(id int) *ProfileReply { return &ProfileReply{ID: id} } // TODO: Write tests for this func (r *ProfileReply) Delete() error { _, err := profileReplyStmts.delete.Exec(r.ID) if err != nil { return err } // TODO: Better coupling between the two paramsextra queries aids, err := Activity.AidsByParamsExtra("reply", r.ParentID, "user", strconv.Itoa(r.ID)) if err != nil { return err } for _, aid := range aids { DismissAlert(r.ParentID, aid) } err = Activity.DeleteByParamsExtra("reply", r.ParentID, "user", strconv.Itoa(r.ID)) return err } func (r *ProfileReply) SetBody(content string) error { content = PreparseMessage(html.UnescapeString(content)) _, err := profileReplyStmts.edit.Exec(content, ParseMessage(content, 0, "", nil, nil), r.ID) return err } // TODO: We can get this from the topic store instead of a query which will always miss the cache... func (r *ProfileReply) Creator() (*User, error) { return Users.Get(r.CreatedBy) } ================================================ FILE: common/profile_reply_store.go ================================================ package common import ( "database/sql" qgen "github.com/Azareal/Gosora/query_gen" ) var Prstore ProfileReplyStore type ProfileReplyStore interface { Get(id int) (*ProfileReply, error) Exists(id int) bool ClearIPs() error Create(profileID int, content string, createdBy int, ip string) (id int, err error) Count() (count int) } // TODO: Refactor this to stop using the global stmt store // TODO: Add more methods to this like Create() type SQLProfileReplyStore struct { get *sql.Stmt exists *sql.Stmt create *sql.Stmt count *sql.Stmt clearIPs *sql.Stmt } func NewSQLProfileReplyStore(acc *qgen.Accumulator) (*SQLProfileReplyStore, error) { ur := "users_replies" return &SQLProfileReplyStore{ get: acc.Select(ur).Columns("uid,content,createdBy,createdAt,lastEdit,lastEditBy,ip").Where("rid=?").Stmt(), exists: acc.Exists(ur, "rid").Prepare(), create: acc.Insert(ur).Columns("uid,content,parsed_content,createdAt,createdBy,ip").Fields("?,?,?,UTC_TIMESTAMP(),?,?").Prepare(), count: acc.Count(ur).Stmt(), clearIPs: acc.Update(ur).Set("ip=''").Where("ip!=''").Stmt(), }, acc.FirstError() } func (s *SQLProfileReplyStore) Get(id int) (*ProfileReply, error) { r := ProfileReply{ID: id} e := s.get.QueryRow(id).Scan(&r.ParentID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.IP) return &r, e } func (s *SQLProfileReplyStore) Exists(id int) bool { e := s.exists.QueryRow(id).Scan(&id) if e != nil && e != ErrNoRows { LogError(e) } return e != ErrNoRows } func (s *SQLProfileReplyStore) ClearIPs() error { _, e := s.clearIPs.Exec() return e } func (s *SQLProfileReplyStore) Create(profileID int, content string, createdBy int, ip string) (id int, e error) { if Config.DisablePostIP { ip = "" } res, e := s.create.Exec(profileID, content, ParseMessage(content, 0, "", nil, nil), createdBy, ip) if e != nil { return 0, e } lastID, e := res.LastInsertId() if e != nil { return 0, e } // Should we reload the user? return int(lastID), e } // TODO: Write a test for this // Count returns the total number of topic replies on these forums func (s *SQLProfileReplyStore) Count() (count int) { return Count(s.count) } ================================================ FILE: common/promotions.go ================================================ package common import ( "database/sql" //"log" "time" qgen "github.com/Azareal/Gosora/query_gen" ) var GroupPromotions GroupPromotionStore type GroupPromotion struct { ID int From int To int TwoWay bool Level int Posts int MinTime int RegisteredFor int } type GroupPromotionStore interface { GetByGroup(gid int) (gps []*GroupPromotion, err error) Get(id int) (*GroupPromotion, error) PromoteIfEligible(u *User, level, posts int, registeredAt time.Time) error Delete(id int) error Create(from, to int, twoWay bool, level, posts, registeredFor int) (int, error) } type DefaultGroupPromotionStore struct { getByGroup *sql.Stmt get *sql.Stmt delete *sql.Stmt create *sql.Stmt getByUser *sql.Stmt getByUserMins *sql.Stmt updateUser *sql.Stmt updateGeneric *sql.Stmt } func NewDefaultGroupPromotionStore(acc *qgen.Accumulator) (*DefaultGroupPromotionStore, error) { ugp := "users_groups_promotions" prs := &DefaultGroupPromotionStore{ getByGroup: acc.Select(ugp).Columns("pid, from_gid, to_gid, two_way, level, posts, minTime, registeredFor").Where("from_gid=? OR to_gid=?").Prepare(), get: acc.Select(ugp).Columns("from_gid, to_gid, two_way, level, posts, minTime, registeredFor").Where("pid=?").Prepare(), delete: acc.Delete(ugp).Where("pid=?").Prepare(), create: acc.Insert(ugp).Columns("from_gid, to_gid, two_way, level, posts, minTime, registeredFor").Fields("?,?,?,?,?,?,?").Prepare(), getByUserMins: acc.Select(ugp).Columns("pid, to_gid, two_way, level, posts, minTime, registeredFor").Where("from_gid=? AND level<=? AND posts<=? AND registeredFor<=?").Orderby("level DESC").Limit("1").Prepare(), getByUser: acc.Select(ugp).Columns("pid, to_gid, two_way, level, posts, minTime, registeredFor").Where("from_gid=? AND level<=? AND posts<=?").Orderby("level DESC").Limit("1").Prepare(), updateUser: acc.Update("users").Set("group=?").Where("group=? AND uid=?").Prepare(), updateGeneric: acc.Update("users").Set("group=?").Where("group=? AND level>=? AND posts>=?").Prepare(), } Tasks.FifteenMin.Add(prs.Tick) return prs, acc.FirstError() } func (s *DefaultGroupPromotionStore) Tick() error { return nil } func (s *DefaultGroupPromotionStore) GetByGroup(gid int) (gps []*GroupPromotion, err error) { rows, err := s.getByGroup.Query(gid, gid) if err != nil { return nil, err } defer rows.Close() for rows.Next() { g := &GroupPromotion{} err := rows.Scan(&g.ID, &g.From, &g.To, &g.TwoWay, &g.Level, &g.Posts, &g.MinTime, &g.RegisteredFor) if err != nil { return nil, err } gps = append(gps, g) } return gps, rows.Err() } // TODO: Cache the group promotions to avoid hitting the database as much func (s *DefaultGroupPromotionStore) Get(id int) (*GroupPromotion, error) { /*g, err := s.cache.Get(id) if err == nil { return u, nil }*/ g := &GroupPromotion{ID: id} err := s.get.QueryRow(id).Scan(&g.From, &g.To, &g.TwoWay, &g.Level, &g.Posts, &g.MinTime, &g.RegisteredFor) if err == nil { //s.cache.Set(u) } return g, err } // TODO: Optimise this to avoid the query func (s *DefaultGroupPromotionStore) PromoteIfEligible(u *User, level, posts int, registeredAt time.Time) error { mins := time.Since(registeredAt).Minutes() g := &GroupPromotion{From: u.Group} //log.Printf("pre getByUserMins: %+v\n", u) err := s.getByUserMins.QueryRow(u.Group, level, posts, mins).Scan(&g.ID, &g.To, &g.TwoWay, &g.Level, &g.Posts, &g.MinTime, &g.RegisteredFor) if err == sql.ErrNoRows { //log.Print("no matches found") return nil } else if err != nil { return err } //log.Printf("g: %+v\n", g) if g.RegisteredFor == 0 { _, err = s.updateGeneric.Exec(g.To, g.From, g.Level, g.Posts) } else { _, err = s.updateUser.Exec(g.To, g.From, u.ID) } return err } func (s *DefaultGroupPromotionStore) Delete(id int) error { _, err := s.delete.Exec(id) return err } func (s *DefaultGroupPromotionStore) Create(from, to int, twoWay bool, level, posts, registeredFor int) (int, error) { res, err := s.create.Exec(from, to, twoWay, level, posts, 0, registeredFor) if err != nil { return 0, err } lastID, err := res.LastInsertId() return int(lastID), err } ================================================ FILE: common/ratelimit.go ================================================ package common import ( "errors" "strconv" "sync" "time" ) var ErrBadRateLimiter = errors.New("That rate limiter doesn't exist") var ErrExceededRateLimit = errors.New("You're exceeding a rate limit. Please wait a while before trying again.") // TODO: Persist rate limits to disk type RateLimiter interface { LimitIP(limit, ip string) error LimitUser(limit string, user int) error } type RateData struct { value int floorTime int } type RateFence struct { duration int max int } // TODO: Optimise this by using something other than a string when possible type RateLimit struct { data map[string][]RateData fences []RateFence sync.RWMutex } func NewRateLimit(fences []RateFence) *RateLimit { for i, fence := range fences { fences[i].duration = fence.duration * 1000 * 1000 * 1000 } return &RateLimit{data: make(map[string][]RateData), fences: fences} } func (l *RateLimit) Limit(name string, ltype int) error { l.Lock() defer l.Unlock() data, ok := l.data[name] if !ok { data = make([]RateData, len(l.fences)) for i, _ := range data { data[i] = RateData{0, int(time.Now().Unix())} } } for i, field := range data { fence := l.fences[i] diff := int(time.Now().Unix()) - field.floorTime if diff >= fence.duration { field = RateData{0, int(time.Now().Unix())} data[i] = field } if field.value > fence.max { return ErrExceededRateLimit } field.value++ data[i] = field } return nil } type DefaultRateLimiter struct { limits map[string]*RateLimit } func NewDefaultRateLimiter() *DefaultRateLimiter { return &DefaultRateLimiter{map[string]*RateLimit{ "register": NewRateLimit([]RateFence{{int(time.Hour / 2), 1}}), }} } func (l *DefaultRateLimiter) LimitIP(limit, ip string) error { limiter, ok := l.limits[limit] if !ok { return ErrBadRateLimiter } return limiter.Limit(ip, 0) } func (l *DefaultRateLimiter) LimitUser(limit string, user int) error { limiter, ok := l.limits[limit] if !ok { return ErrBadRateLimiter } return limiter.Limit(strconv.Itoa(user), 1) } ================================================ FILE: common/recalc.go ================================================ package common import ( "database/sql" //"log" "strconv" qgen "github.com/Azareal/Gosora/query_gen" ) var Recalc RecalcInt type RecalcInt interface { Replies() (count int, err error) Forums() (count int, err error) Subscriptions() (count int, err error) ActivityStream() (count int, err error) Users() error Attachments() (count int, err error) } type DefaultRecalc struct { getActivitySubscriptions *sql.Stmt getActivityStream *sql.Stmt getAttachments *sql.Stmt getTopicCount *sql.Stmt resetTopicCount *sql.Stmt } func NewDefaultRecalc(acc *qgen.Accumulator) (*DefaultRecalc, error) { return &DefaultRecalc{ getActivitySubscriptions: acc.Select("activity_subscriptions").Columns("targetID,targetType").Prepare(), getActivityStream: acc.Select("activity_stream").Columns("asid,event,elementID,elementType,extra").Prepare(), getAttachments: acc.Select("attachments").Columns("attachID,originID,originTable").Prepare(), getTopicCount: acc.Count("topics").Where("parentID=?").Prepare(), //resetTopicCount: acc.SimpleUpdateSelect("forums", "topicCount = tc", "topics", "count(*) as tc", "parentID=?", "", ""), // TODO: Avoid using RawPrepare resetTopicCount: acc.RawPrepare("UPDATE forums, (SELECT COUNT(*) as tc FROM topics WHERE parentID=?) AS src SET forums.topicCount=src.tc WHERE forums.fid=?"), }, acc.FirstError() } func (s *DefaultRecalc) Replies() (count int, err error) { var ltid int err = Rstore.Each(func(r *Reply) error { if ltid == r.ParentID && r.ParentID > 0 { //return nil } if !Topics.Exists(r.ParentID) { // TODO: Delete in chunks not one at a time? if err := r.Delete(); err != nil { return err } count++ } return nil }) return count, err } func (s *DefaultRecalc) Forums() (count int, err error) { err = Forums.Each(func(f *Forum) error { _, err := s.resetTopicCount.Exec(f.ID, f.ID) if err != nil { return err } count++ return nil }) return count, err } func (s *DefaultRecalc) Subscriptions() (count int, err error) { err = eachall(s.getActivitySubscriptions, func(r *sql.Rows) error { var targetID int var targetType string err := r.Scan(&targetID, &targetType) if err != nil { return err } if targetType == "topic" { if !Topics.Exists(targetID) { // TODO: Delete in chunks not one at a time? err := Subscriptions.DeleteResource(targetID, targetType) if err != nil { return err } count++ } } return nil }) return count, err } type Existable interface { Exists(id int) bool } func (s *DefaultRecalc) ActivityStream() (count int, err error) { err = eachall(s.getActivityStream, func(r *sql.Rows) error { var asid, elementID int var event, elementType, extra string err := r.Scan(&asid, &event, &elementID, &elementType, &extra) if err != nil { return err } //log.Print("asid:",asid) var s Existable switch elementType { case "user": if event == "reply" { extraI, _ := strconv.Atoi(extra) if extraI > 0 { s = Prstore elementID = extraI } else { return nil } } else { return nil } case "topic": s = Topics // TODO: Delete reply events with an empty extra field if event == "reply" { extraI, _ := strconv.Atoi(extra) if extraI > 0 { s = Rstore elementID = extraI } } case "post": s = Rstore // TODO: Add a TopicExistsByReplyID for efficiency /*_, err = TopicByReplyID(elementID) if err == sql.ErrNoRows { // TODO: Delete in chunks not one at a time? err := Activity.Delete(asid) if err != nil { return err } count++ } else if err != nil { return err }*/ default: return nil } if !s.Exists(elementID) { // TODO: Delete in chunks not one at a time? err := Activity.Delete(asid) if err != nil { return err } count++ } return nil }) return count, err } func (s *DefaultRecalc) Users() error { return Users.Each(func(u *User) error { return u.RecalcPostStats() }) } func (s *DefaultRecalc) Attachments() (count int, err error) { err = eachall(s.getAttachments, func(r *sql.Rows) error { var aid, originID int var originType string err := r.Scan(&aid, &originID, &originType) if err != nil { return err } var s Existable switch originType { case "topics": s = Topics case "replies": s = Rstore default: return nil } if !s.Exists(originID) { // TODO: Delete in chunks not one at a time? err := Attachments.Delete(aid) if err != nil { return err } count++ } return nil }) return count, err } ================================================ FILE: common/relations.go ================================================ package common import ( "database/sql" qgen "github.com/Azareal/Gosora/query_gen" ) var UserBlocks BlockStore //var UserFriends FriendStore type BlockStore interface { IsBlockedBy(blocker, blockee int) (bool, error) BulkIsBlockedBy(blockers []int, blockee int) (bool, error) Add(blocker, blockee int) error Remove(blocker, blockee int) error BlockedByOffset(blocker, offset, perPage int) ([]int, error) BlockedByCount(blocker int) int } type DefaultBlockStore struct { isBlocked *sql.Stmt add *sql.Stmt remove *sql.Stmt blockedBy *sql.Stmt blockedByCount *sql.Stmt } func NewDefaultBlockStore(acc *qgen.Accumulator) (*DefaultBlockStore, error) { ub := "users_blocks" return &DefaultBlockStore{ isBlocked: acc.Select(ub).Cols("blocker").Where("blocker=? AND blockedUser=?").Prepare(), add: acc.Insert(ub).Columns("blocker,blockedUser").Fields("?,?").Prepare(), remove: acc.Delete(ub).Where("blocker=? AND blockedUser=?").Prepare(), blockedBy: acc.Select(ub).Columns("blockedUser").Where("blocker=?").Limit("?,?").Prepare(), blockedByCount: acc.Count(ub).Where("blocker=?").Prepare(), }, acc.FirstError() } func (s *DefaultBlockStore) IsBlockedBy(blocker, blockee int) (bool, error) { e := s.isBlocked.QueryRow(blocker, blockee).Scan(&blocker) if e == ErrNoRows { return false, nil } return e == nil, e } // TODO: Optimise the query to avoid preparing it on the spot? Maybe, use knowledge of the most common IN() parameter counts? func (s *DefaultBlockStore) BulkIsBlockedBy(blockers []int, blockee int) (bool, error) { if len(blockers) == 0 { return false, nil } if len(blockers) == 1 { return s.IsBlockedBy(blockers[0], blockee) } idList, q := inqbuild(blockers) count, e := qgen.NewAcc().Count("users_blocks").Where("blocker IN(" + q + ") AND blockedUser=?").TotalP(idList...) if e == ErrNoRows { return false, nil } return count == 0, e } func (s *DefaultBlockStore) Add(blocker, blockee int) error { _, e := s.add.Exec(blocker, blockee) return e } func (s *DefaultBlockStore) Remove(blocker, blockee int) error { _, e := s.remove.Exec(blocker, blockee) return e } func (s *DefaultBlockStore) BlockedByOffset(blocker, offset, perPage int) (uids []int, err error) { rows, e := s.blockedBy.Query(blocker, offset, perPage) if e != nil { return nil, e } defer rows.Close() for rows.Next() { var uid int if e := rows.Scan(&uid); e != nil { return nil, e } uids = append(uids, uid) } return uids, rows.Err() } func (s *DefaultBlockStore) BlockedByCount(blocker int) (count int) { e := s.blockedByCount.QueryRow(blocker).Scan(&count) if e != nil { LogError(e) } return count } type FriendInvite struct { Requester int Target int } type FriendStore interface { AddInvite(requester, target int) error Confirm(requester, target int) error GetOwSentInvites(uid int) ([]FriendInvite, error) GetOwnRecvInvites(uid int) ([]FriendInvite, error) } type DefaultFriendStore struct { addInvite *sql.Stmt confirm *sql.Stmt getOwnSentInvites *sql.Stmt getOwnRecvInvites *sql.Stmt } func NewDefaultFriendStore(acc *qgen.Accumulator) (*DefaultFriendStore, error) { ufi := "users_friends_invites" return &DefaultFriendStore{ addInvite: acc.Insert(ufi).Columns("requester,target").Fields("?,?").Prepare(), confirm: acc.Insert("users_friends").Columns("uid,uid2").Fields("?,?").Prepare(), getOwnSentInvites: acc.Select(ufi).Cols("requester,target").Where("requester=?").Prepare(), getOwnRecvInvites: acc.Select(ufi).Cols("requester,target").Where("target=?").Prepare(), }, acc.FirstError() } func (s *DefaultFriendStore) AddInvite(requester, target int) error { _, e := s.addInvite.Exec(requester, target) return e } func (s *DefaultFriendStore) Confirm(requester, target int) error { _, e := s.confirm.Exec(requester, target) return e } func (s *DefaultFriendStore) GetOwnSentInvites(uid int) ([]FriendInvite, error) { return nil, nil } func (s *DefaultFriendStore) GetOwnRecvInvites(uid int) ([]FriendInvite, error) { return nil, nil } ================================================ FILE: common/reply.go ================================================ /* * * Reply Resources File * Copyright Azareal 2016 - 2020 * */ package common import ( "database/sql" "errors" "html" "strconv" "time" qgen "github.com/Azareal/Gosora/query_gen" ) type ReplyUser struct { Reply ContentHtml string UserLink string CreatedByName string Avatar string MicroAvatar string ClassName string Tag string URL string //URLPrefix string //URLName string Group int Level int ActionIcon string Attachments []*MiniAttachment Deletable bool } type Reply struct { ID int ParentID int Content string CreatedBy int //Group int CreatedAt time.Time LastEdit int LastEditBy int ContentLines int IP string Liked bool LikeCount int AttachCount uint16 ActionType string } var ErrAlreadyLiked = errors.New("You already liked this!") var replyStmts ReplyStmts type ReplyStmts struct { isLiked *sql.Stmt createLike *sql.Stmt edit *sql.Stmt setPoll *sql.Stmt delete *sql.Stmt addLikesToReply *sql.Stmt removeRepliesFromTopic *sql.Stmt deleteLikesForReply *sql.Stmt deleteActivity *sql.Stmt deleteActivitySubs *sql.Stmt updateTopicReplies *sql.Stmt updateTopicReplies2 *sql.Stmt getAidsOfReply *sql.Stmt } func init() { DbInits.Add(func(acc *qgen.Accumulator) error { re := "replies" replyStmts = ReplyStmts{ isLiked: acc.Select("likes").Columns("targetItem").Where("sentBy=? and targetItem=? and targetType='replies'").Prepare(), createLike: acc.Insert("likes").Columns("weight,targetItem,targetType,sentBy,createdAt").Fields("?,?,?,?,UTC_TIMESTAMP()").Prepare(), edit: acc.Update(re).Set("content=?,parsed_content=?").Where("rid=? AND poll=0").Prepare(), setPoll: acc.Update(re).Set("poll=?").Where("rid=? AND poll=0").Prepare(), delete: acc.Delete(re).Where("rid=?").Prepare(), addLikesToReply: acc.Update(re).Set("likeCount=likeCount+?").Where("rid=?").Prepare(), removeRepliesFromTopic: acc.Update("topics").Set("postCount=postCount-?").Where("tid=?").Prepare(), deleteLikesForReply: acc.Delete("likes").Where("targetItem=? AND targetType='replies'").Prepare(), deleteActivity: acc.Delete("activity_stream").Where("elementID=? AND elementType='post'").Prepare(), deleteActivitySubs: acc.Delete("activity_subscriptions").Where("targetID=? AND targetType='post'").Prepare(), // TODO: Optimise this to avoid firing an update if it's not the last reply in a topic. We will need to set lastReplyID properly in other places and in the patcher first so we can use it here. updateTopicReplies: acc.RawPrepare("UPDATE topics t INNER JOIN replies r ON t.tid=r.tid SET t.lastReplyBy=r.createdBy, t.lastReplyAt=r.createdAt, t.lastReplyID=r.rid WHERE t.tid=? ORDER BY r.rid DESC"), updateTopicReplies2: acc.Update("topics").Set("lastReplyAt=createdAt,lastReplyBy=createdBy,lastReplyID=0").Where("postCount=1 AND tid=?").Prepare(), getAidsOfReply: acc.Select("attachments").Columns("attachID").Where("originID=? AND originTable='replies'").Prepare(), } return acc.FirstError() }) } // TODO: Write tests for this // TODO: Wrap these queries in a transaction to make sure the state is consistent func (r *Reply) Like(uid int) (err error) { var rid int // unused, just here to avoid mutating reply.ID err = replyStmts.isLiked.QueryRow(uid, r.ID).Scan(&rid) if err != nil && err != ErrNoRows { return err } else if err != ErrNoRows { return ErrAlreadyLiked } score := 1 _, err = replyStmts.createLike.Exec(score, r.ID, "replies", uid) if err != nil { return err } _, err = replyStmts.addLikesToReply.Exec(1, r.ID) if err != nil { return err } _, err = userStmts.incLiked.Exec(1, uid) _ = Rstore.GetCache().Remove(r.ID) return err } // TODO: Use a transaction func (r *Reply) Unlike(uid int) error { err := Likes.Delete(r.ID, "replies") if err != nil { return err } _, err = replyStmts.addLikesToReply.Exec(-1, r.ID) if err != nil { return err } _, err = userStmts.decLiked.Exec(1, uid) _ = Rstore.GetCache().Remove(r.ID) return err } // TODO: Refresh topic list? func (r *Reply) Delete() error { creator, err := Users.Get(r.CreatedBy) if err == nil { err = creator.DecreasePostStats(WordCount(r.Content), false) if err != nil { return err } } else if err != ErrNoRows { return err } _, err = replyStmts.delete.Exec(r.ID) if err != nil { return err } // TODO: Move this bit to *Topic _, err = replyStmts.removeRepliesFromTopic.Exec(1, r.ParentID) if err != nil { return err } _, err = replyStmts.updateTopicReplies.Exec(r.ParentID) if err != nil { return err } _, err = replyStmts.updateTopicReplies2.Exec(r.ParentID) tc := Topics.GetCache() if tc != nil { tc.Remove(r.ParentID) } _ = Rstore.GetCache().Remove(r.ID) if err != nil { return err } _, err = replyStmts.deleteLikesForReply.Exec(r.ID) if err != nil { return err } err = handleReplyAttachments(r.ID) if err != nil { return err } err = Activity.DeleteByParamsExtra("reply", r.ParentID, "topic", strconv.Itoa(r.ID)) if err != nil { return err } _, err = replyStmts.deleteActivitySubs.Exec(r.ID) if err != nil { return err } _, err = replyStmts.deleteActivity.Exec(r.ID) return err } func (r *Reply) SetPost(content string) error { topic, err := r.Topic() if err != nil { return err } content = PreparseMessage(html.UnescapeString(content)) parsedContent := ParseMessage(content, topic.ParentID, "forums", nil, nil) _, err = replyStmts.edit.Exec(content, parsedContent, r.ID) // TODO: Sniff if this changed anything to see if we hit an existing poll _ = Rstore.GetCache().Remove(r.ID) return err } // TODO: Write tests for this func (r *Reply) SetPoll(pollID int) error { _, err := replyStmts.setPoll.Exec(pollID, r.ID) // TODO: Sniff if this changed anything to see if we hit a poll _ = Rstore.GetCache().Remove(r.ID) return err } func (r *Reply) Topic() (*Topic, error) { return Topics.Get(r.ParentID) } func (r *Reply) GetID() int { return r.ID } func (r *Reply) GetTable() string { return "replies" } // Copy gives you a non-pointer concurrency safe copy of the reply func (r *Reply) Copy() Reply { return *r } ================================================ FILE: common/reply_cache.go ================================================ package common import ( //"log" "sync" "sync/atomic" ) // ReplyCache is an interface which spits out replies from a fast cache rather than the database, whether from memory or from an application like Redis. Replies may not be present in the cache but may be in the database type ReplyCache interface { Get(id int) (*Reply, error) GetUnsafe(id int) (*Reply, error) BulkGet(ids []int) (list []*Reply) Set(item *Reply) error Add(item *Reply) error AddUnsafe(item *Reply) error Remove(id int) error RemoveUnsafe(id int) error Flush() Length() int SetCapacity(cap int) GetCapacity() int } // MemoryReplyCache stores and pulls replies out of the current process' memory type MemoryReplyCache struct { items map[int]*Reply length int64 // sync/atomic only lets us operate on int32s and int64s capacity int sync.RWMutex } // NewMemoryReplyCache gives you a new instance of MemoryReplyCache func NewMemoryReplyCache(cap int) *MemoryReplyCache { return &MemoryReplyCache{ items: make(map[int]*Reply), capacity: cap, } } // Get fetches a reply by ID. Returns ErrNoRows if not present. func (s *MemoryReplyCache) Get(id int) (*Reply, error) { s.RLock() item, ok := s.items[id] s.RUnlock() if ok { return item, nil } return item, ErrNoRows } // GetUnsafe fetches a reply by ID. Returns ErrNoRows if not present. THIS METHOD IS NOT THREAD-SAFE. func (s *MemoryReplyCache) GetUnsafe(id int) (*Reply, error) { item, ok := s.items[id] if ok { return item, nil } return item, ErrNoRows } // BulkGet fetches multiple replies by their IDs. Indices without replies will be set to nil, so make sure you check for those, we might want to change this behaviour to make it less confusing. func (s *MemoryReplyCache) BulkGet(ids []int) (list []*Reply) { list = make([]*Reply, len(ids)) s.RLock() for i, id := range ids { list[i] = s.items[id] } s.RUnlock() return list } // Set overwrites the value of a reply in the cache, whether it's present or not. May return a capacity overflow error. func (s *MemoryReplyCache) Set(item *Reply) error { s.Lock() _, ok := s.items[item.ID] if ok { s.items[item.ID] = item } else if int(s.length) >= s.capacity { s.Unlock() return ErrStoreCapacityOverflow } else { s.items[item.ID] = item atomic.AddInt64(&s.length, 1) } s.Unlock() return nil } // Add adds a reply to the cache, similar to Set, but it's only intended for new items. This method might be deprecated in the near future, use Set. May return a capacity overflow error. // ? Is this redundant if we have Set? Are the efficiency wins worth this? Is this even used? func (s *MemoryReplyCache) Add(item *Reply) error { //log.Print("MemoryReplyCache.Add") s.Lock() if int(s.length) >= s.capacity { s.Unlock() return ErrStoreCapacityOverflow } s.items[item.ID] = item s.Unlock() atomic.AddInt64(&s.length, 1) return nil } // AddUnsafe is the unsafe version of Add. May return a capacity overflow error. THIS METHOD IS NOT THREAD-SAFE. func (s *MemoryReplyCache) AddUnsafe(item *Reply) error { if int(s.length) >= s.capacity { return ErrStoreCapacityOverflow } s.items[item.ID] = item s.length = int64(len(s.items)) return nil } // Remove removes a reply from the cache by ID, if they exist. Returns ErrNoRows if no items exist. func (s *MemoryReplyCache) Remove(id int) error { s.Lock() _, ok := s.items[id] if !ok { s.Unlock() return ErrNoRows } delete(s.items, id) s.Unlock() atomic.AddInt64(&s.length, -1) return nil } // RemoveUnsafe is the unsafe version of Remove. THIS METHOD IS NOT THREAD-SAFE. func (s *MemoryReplyCache) RemoveUnsafe(id int) error { _, ok := s.items[id] if !ok { return ErrNoRows } delete(s.items, id) atomic.AddInt64(&s.length, -1) return nil } // Flush removes all the replies from the cache, useful for tests. func (s *MemoryReplyCache) Flush() { s.Lock() s.items = make(map[int]*Reply) s.length = 0 s.Unlock() } // ! Is this concurrent? // Length returns the number of replies in the memory cache func (s *MemoryReplyCache) Length() int { return int(s.length) } // SetCapacity sets the maximum number of replies which this cache can hold func (s *MemoryReplyCache) SetCapacity(cap int) { // Ints are moved in a single instruction, so this should be thread-safe s.capacity = cap } // GetCapacity returns the maximum number of replies this cache can hold func (s *MemoryReplyCache) GetCapacity() int { return s.capacity } ================================================ FILE: common/reply_store.go ================================================ package common //import "log" import ( "database/sql" qgen "github.com/Azareal/Gosora/query_gen" ) var Rstore ReplyStore type ReplyStore interface { Get(id int) (*Reply, error) Each(f func(*Reply) error) error Exists(id int) bool ClearIPs() error Create(t *Topic, content, ip string, uid int) (id int, err error) Count() (count int) CountUser(uid int) (count int) CountMegaUser(uid int) (count int) CountBigUser(uid int) (count int) SetCache(cache ReplyCache) GetCache() ReplyCache } type SQLReplyStore struct { cache ReplyCache get *sql.Stmt getAll *sql.Stmt exists *sql.Stmt create *sql.Stmt count *sql.Stmt countUser *sql.Stmt countWordUser *sql.Stmt clearIPs *sql.Stmt } func NewSQLReplyStore(acc *qgen.Accumulator, cache ReplyCache) (*SQLReplyStore, error) { if cache == nil { cache = NewNullReplyCache() } re := "replies" return &SQLReplyStore{ cache: cache, get: acc.Select(re).Columns("tid,content,createdBy,createdAt,lastEdit,lastEditBy,ip,likeCount,attachCount,actionType").Where("rid=?").Prepare(), getAll: acc.Select(re).Columns("rid,tid,content,createdBy,createdAt,lastEdit,lastEditBy,ip,likeCount,attachCount,actionType").Prepare(), exists: acc.Exists(re, "rid").Prepare(), create: acc.Insert(re).Columns("tid,content,parsed_content,createdAt,lastUpdated,ip,words,createdBy").Fields("?,?,?,UTC_TIMESTAMP(),UTC_TIMESTAMP(),?,?,?").Prepare(), count: acc.Count(re).Prepare(), countUser: acc.Count(re).Where("createdBy=?").Prepare(), countWordUser: acc.Count(re).Where("createdBy=? AND words>=?").Prepare(), clearIPs: acc.Update(re).Set("ip=''").Where("ip!=''").Stmt(), }, acc.FirstError() } func (s *SQLReplyStore) Get(id int) (*Reply, error) { r, err := s.cache.Get(id) if err == nil { return r, nil } r = &Reply{ID: id} err = s.get.QueryRow(id).Scan(&r.ParentID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.IP, &r.LikeCount, &r.AttachCount, &r.ActionType) if err == nil { _ = s.cache.Set(r) } return r, err } /*func (s *SQLReplyStore) eachr(f func(*sql.Rows) error) error { return eachall(s.getAll, f) }*/ func (s *SQLReplyStore) Each(f func(*Reply) error) error { rows, err := s.getAll.Query() if err != nil { return err } defer rows.Close() for rows.Next() { r := new(Reply) if err := rows.Scan(&r.ID, &r.ParentID, &r.Content, &r.CreatedBy, &r.CreatedAt, &r.LastEdit, &r.LastEditBy, &r.IP, &r.LikeCount, &r.AttachCount, &r.ActionType); err != nil { return err } if err := f(r); err != nil { return err } } return rows.Err() } func (s *SQLReplyStore) Exists(id int) bool { err := s.exists.QueryRow(id).Scan(&id) if err != nil && err != ErrNoRows { LogError(err) } return err != ErrNoRows } func (s *SQLReplyStore) ClearIPs() error { _, e := s.clearIPs.Exec() return e } // TODO: Write a test for this func (s *SQLReplyStore) Create(t *Topic, content, ip string, uid int) (id int, err error) { if Config.DisablePostIP { ip = "" } res, err := s.create.Exec(t.ID, content, ParseMessage(content, t.ParentID, "forums", nil, nil), ip, WordCount(content), uid) if err != nil { return 0, err } lastID, err := res.LastInsertId() if err != nil { return 0, err } id = int(lastID) return id, t.AddReply(id, uid) } // TODO: Write a test for this // Count returns the total number of topic replies on these forums func (s *SQLReplyStore) Count() (count int) { return Countf(s.count) } func (s *SQLReplyStore) CountUser(uid int) (count int) { return Countf(s.countUser, uid) } func (s *SQLReplyStore) CountMegaUser(uid int) (count int) { return Countf(s.countWordUser, uid, SettingBox.Load().(SettingMap)["megapost_min_words"].(int)) } func (s *SQLReplyStore) CountBigUser(uid int) (count int) { return Countf(s.countWordUser, uid, SettingBox.Load().(SettingMap)["bigpost_min_words"].(int)) } func (s *SQLReplyStore) SetCache(cache ReplyCache) { s.cache = cache } func (s *SQLReplyStore) GetCache() ReplyCache { return s.cache } ================================================ FILE: common/report_store.go ================================================ package common import ( "database/sql" "errors" "strconv" qgen "github.com/Azareal/Gosora/query_gen" ) // TODO: Make the default report forum ID configurable // TODO: Make sure this constant is used everywhere for the report forum ID const ReportForumID = 1 var Reports ReportStore var ErrAlreadyReported = errors.New("This item has already been reported") // The report system mostly wraps around the topic system for simplicty type ReportStore interface { Create(title, content string, u *User, itemType string, itemID int) (int, error) } type DefaultReportStore struct { create *sql.Stmt exists *sql.Stmt } func NewDefaultReportStore(acc *qgen.Accumulator) (*DefaultReportStore, error) { t := "topics" return &DefaultReportStore{ create: acc.Insert(t).Columns("title, content, parsed_content, ip, createdAt, lastReplyAt, createdBy, lastReplyBy, data, parentID, css_class").Fields("?,?,?,?,UTC_TIMESTAMP(),UTC_TIMESTAMP(),?,?,?,?,'report'").Prepare(), exists: acc.Count(t).Where("data=? AND data!='' AND parentID=?").Prepare(), }, acc.FirstError() } // ! There's a data race in this. If two users report one item at the exact same time, then both reports will go through func (s *DefaultReportStore) Create(title, content string, u *User, itemType string, itemID int) (tid int, err error) { var count int err = s.exists.QueryRow(itemType+"_"+strconv.Itoa(itemID), ReportForumID).Scan(&count) if err != nil && err != sql.ErrNoRows { return 0, err } if count != 0 { return 0, ErrAlreadyReported } ip := u.GetIP() if Config.DisablePostIP { ip = "" } res, err := s.create.Exec(title, content, ParseMessage(content, 0, "", nil, nil), ip, u.ID, u.ID, itemType+"_"+strconv.Itoa(itemID), ReportForumID) if err != nil { return 0, err } lastID, err := res.LastInsertId() if err != nil { return 0, err } tid = int(lastID) return tid, Forums.AddTopic(tid, u.ID, ReportForumID) } ================================================ FILE: common/routes_common.go ================================================ package common import ( "crypto/subtle" "html" "io" "net" "net/http" "os" "regexp" "strconv" "strings" "time" "github.com/Azareal/Gosora/common/phrases" "github.com/Azareal/Gosora/uutils" ) // nolint var PreRoute func(http.ResponseWriter, *http.Request) (User, bool) = preRoute // TODO: Come up with a better middleware solution // nolint We need these types so people can tell what they are without scrolling to the bottom of the file var PanelUserCheck func(http.ResponseWriter, *http.Request, *User) (*Header, PanelStats, RouteError) = panelUserCheck var SimplePanelUserCheck func(http.ResponseWriter, *http.Request, *User) (*HeaderLite, RouteError) = simplePanelUserCheck var SimpleForumUserCheck func(w http.ResponseWriter, r *http.Request, u *User, fid int) (headerLite *HeaderLite, err RouteError) = simpleForumUserCheck var ForumUserCheck func(h *Header, w http.ResponseWriter, r *http.Request, u *User, fid int) (err RouteError) = forumUserCheck var SimpleUserCheck func(w http.ResponseWriter, r *http.Request, u *User) (headerLite *HeaderLite, err RouteError) = simpleUserCheck var UserCheck func(w http.ResponseWriter, r *http.Request, u *User) (h *Header, err RouteError) = userCheck var UserCheckNano func(w http.ResponseWriter, r *http.Request, u *User, nano int64) (h *Header, err RouteError) = userCheck2 func simpleForumUserCheck(w http.ResponseWriter, r *http.Request, u *User, fid int) (h *HeaderLite, rerr RouteError) { h, rerr = SimpleUserCheck(w, r, u) if rerr != nil { return h, rerr } if !Forums.Exists(fid) { return nil, PreError("The target forum doesn't exist.", w, r) } // Is there a better way of doing the skip AND the success flag on this hook like multiple returns? /*skip, rerr := h.Hooks.VhookSkippable("simple_forum_check_pre_perms", w, r, u, &fid, h) if skip || rerr != nil { return h, rerr }*/ skip, rerr := H_simple_forum_check_pre_perms_hook(h.Hooks, w, r, u, &fid, h) if skip || rerr != nil { return h, rerr } fp, err := FPStore.Get(fid, u.Group) if err == ErrNoRows { fp = BlankForumPerms() } else if err != nil { return h, InternalError(err, w, r) } cascadeForumPerms(fp, u) return h, nil } func forumUserCheck(h *Header, w http.ResponseWriter, r *http.Request, u *User, fid int) (rerr RouteError) { if !Forums.Exists(fid) { return NotFound(w, r, h) } /*skip, rerr := h.Hooks.VhookSkippable("forum_check_pre_perms", w, r, u, &fid, h) if skip || rerr != nil { return rerr }*/ /*skip, rerr := VhookSkippableTest(h.Hooks, "forum_check_pre_perms", w, r, u, &fid, h) if skip || rerr != nil { return rerr }*/ skip, rerr := H_forum_check_pre_perms_hook(h.Hooks, w, r, u, &fid, h) if skip || rerr != nil { return rerr } fp, err := FPStore.Get(fid, u.Group) if err == ErrNoRows { fp = BlankForumPerms() } else if err != nil { return InternalError(err, w, r) } cascadeForumPerms(fp, u) h.CurrentUser = u // TODO: Use a pointer instead for CurrentUser, so we don't have to do this return rerr } // TODO: Put this on the user instance? Do we really want forum specific logic in there? Maybe, a method which spits a new pointer with the same contents as user? func cascadeForumPerms(fp *ForumPerms, u *User) { if fp.Overrides && !u.IsSuperAdmin { u.Perms.ViewTopic = fp.ViewTopic u.Perms.LikeItem = fp.LikeItem u.Perms.CreateTopic = fp.CreateTopic u.Perms.EditTopic = fp.EditTopic u.Perms.DeleteTopic = fp.DeleteTopic u.Perms.CreateReply = fp.CreateReply u.Perms.EditReply = fp.EditReply u.Perms.DeleteReply = fp.DeleteReply u.Perms.PinTopic = fp.PinTopic u.Perms.CloseTopic = fp.CloseTopic u.Perms.MoveTopic = fp.MoveTopic if len(fp.ExtData) != 0 { for name, perm := range fp.ExtData { u.PluginPerms[name] = perm } } } } // Even if they have the right permissions, the control panel is only open to supermods+. There are many areas without subpermissions which assume that the current user is a supermod+ and admins are extremely unlikely to give these permissions to someone who isn't at-least a supermod to begin with // TODO: Do a panel specific theme? func panelUserCheck(w http.ResponseWriter, r *http.Request, u *User) (h *Header, stats PanelStats, rerr RouteError) { theme := GetThemeByReq(r) h = &Header{ Site: Site, Settings: SettingBox.Load().(SettingMap), //Themes: Themes, ThemesSlice: ThemesSlice, Theme: theme, CurrentUser: u, Hooks: GetHookTable(), Zone: "panel", Writer: w, IsoCode: phrases.GetLangPack().IsoCode, //StartedAt: time.Now(), StartedAt: uutils.Nanotime(), } // TODO: We should probably initialise header.ExtData // ? - Should we only show this in debug mode? It might be useful for detecting issues in production, if we show it there as-well //if user.IsAdmin { //h.StartedAt = time.Now() //} h.AddSheet(theme.Name + "/main.css") h.AddSheet(theme.Name + "/panel.css") if len(theme.Resources) > 0 { rlist := theme.Resources for _, res := range rlist { if res.LocID == LocGlobal || res.LocID == LocPanel { if res.Type == ResTypeSheet { h.AddSheet(res.Name) } else if res.Type == ResTypeScript { if res.Async { h.AddScriptAsync(res.Name) } else { h.AddScript(res.Name) } } } } } //h := w.Header() //h.Set("Content-Security-Policy", "default-src 'self'") // TODO: GDPR. Add a global control panel notice warning the admins of staff members who don't have 2FA enabled stats.Users = Users.Count() stats.Groups = Groups.Count() stats.Forums = Forums.Count() stats.Pages = Pages.Count() stats.Settings = len(h.Settings) stats.WordFilters = WordFilters.EstCount() stats.Themes = len(Themes) stats.Reports = 0 // TODO: Do the report count. Only show open threads? addPreScript := func(name string, i int) { // TODO: Optimise this by removing a superfluous string alloc if theme.OverridenMap != nil { //fmt.Printf("name %+v\n", name) //fmt.Printf("theme.OverridenMap %+v\n", theme.OverridenMap) if _, ok := theme.OverridenMap[name]; ok { tname := "_" + theme.Name //fmt.Printf("tname %+v\n", tname) h.AddPreScriptAsync("tmpl_" + name + tname + ".js") return } } //fmt.Printf("tname %+v\n", tname) h.AddPreScriptAsync(ucstrs[i]) } addPreScript("alert", 3) addPreScript("notice", 4) return h, stats, nil } func simplePanelUserCheck(w http.ResponseWriter, r *http.Request, u *User) (lite *HeaderLite, rerr RouteError) { return SimpleUserCheck(w, r, u) } // SimpleUserCheck is back from the grave, yay :D func simpleUserCheck(w http.ResponseWriter, r *http.Request, u *User) (lite *HeaderLite, rerr RouteError) { return &HeaderLite{ Site: Site, Settings: SettingBox.Load().(SettingMap), Hooks: GetHookTable(), }, nil } func GetThemeByReq(r *http.Request) *Theme { theme := &Theme{Name: ""} cookie, e := r.Cookie("current_theme") if e == nil { inTheme, ok := Themes[html.EscapeString(cookie.Value)] if ok && !theme.HideFromThemes { theme = inTheme } } if theme.Name == "" { theme = Themes[DefaultThemeBox.Load().(string)] } return theme } func userCheck(w http.ResponseWriter, r *http.Request, u *User) (h *Header, rerr RouteError) { return userCheck2(w, r, u, uutils.Nanotime()) } // TODO: Add the ability for admins to restrict certain themes to certain groups? // ! Be careful about firing errors off here as CustomError uses this func userCheck2(w http.ResponseWriter, r *http.Request, u *User, nano int64) (h *Header, rerr RouteError) { theme := GetThemeByReq(r) h = &Header{ Site: Site, Settings: SettingBox.Load().(SettingMap), //Themes: Themes, ThemesSlice: ThemesSlice, Theme: theme, CurrentUser: u, // ! Some things rely on this being a pointer downstream from this function Hooks: GetHookTable(), Zone: ucstrs[0], Writer: w, IsoCode: phrases.GetLangPack().IsoCode, StartedAt: nano, } // TODO: Optimise this by avoiding accessing a map string index if !u.Loggedin { h.GoogSiteVerify = h.Settings["google_site_verify"].(string) } if u.IsBanned { h.AddNotice("account_banned") } if u.Loggedin && !u.Active { h.AddNotice("account_inactive") } /*h.Scripts, _ = StrSlicePool.Get().([]string) if h.Scripts != nil { h.Scripts = h.Scripts[:0] } h.PreScriptsAsync, _ = StrSlicePool.Get().([]string) if h.PreScriptsAsync != nil { h.PreScriptsAsync = h.PreScriptsAsync[:0] }*/ // An optimisation so we don't populate StartedAt for users who shouldn't see the stat anyway // ? - Should we only show this in debug mode? It might be useful for detecting issues in production, if we show it there as-well //if u.IsAdmin { //h.StartedAt = time.Now() //} //PrepResources(u,h,theme) return h, nil } func PrepResources(u *User, h *Header, theme *Theme) { h.AddSheet(theme.Name + "/main.css") if len(theme.Resources) > 0 { rlist := theme.Resources for _, res := range rlist { if res.Loggedin && !u.Loggedin { continue } if res.LocID == LocGlobal || res.LocID == LocFront { if res.Type == ResTypeSheet { h.AddSheet(res.Name) } else if res.Type == ResTypeScript { if res.Async { h.AddScriptAsync(res.Name) } else { h.AddScript(res.Name) } } } } } addPreScript := func(name string, i int) { // TODO: Optimise this by removing a superfluous string alloc if theme.OverridenMap != nil { //fmt.Printf("name %+v\n", name) //fmt.Printf("theme.OverridenMap %+v\n", theme.OverridenMap) if _, ok := theme.OverridenMap[name]; ok { tname := "_" + theme.Name //fmt.Printf("tname %+v\n", tname) h.AddPreScriptAsync("tmpl_" + name + tname + ".js") return } } //fmt.Printf("tname %+v\n", tname) h.AddPreScriptAsync(ucstrs[i]) } addPreScript("topics_topic", 1) addPreScript("paginator", 2) addPreScript("alert", 3) addPreScript("notice", 4) if u.Loggedin { addPreScript("topic_c_edit_post", 5) addPreScript("topic_c_attach_item", 6) addPreScript("topic_c_poll_input", 7) } } func pstr(name string) string { return "tmpl_" + name + ".js" } var ucstrs = [...]string{ "frontend", pstr("topics_topic"), pstr("paginator"), pstr("alert"), pstr("notice"), pstr("topic_c_edit_post"), pstr("topic_c_attach_item"), pstr("topic_c_poll_input"), } func preRoute(w http.ResponseWriter, r *http.Request) (User, bool) { userptr, halt := Auth.SessionCheck(w, r) if halt { return *userptr, false } var usercpy *User = BlankUser() *usercpy = *userptr usercpy.Init() // TODO: Can we reduce the amount of work we do here? // TODO: Add a config setting to disable this header // TODO: Have this header cover more things if Config.SslSchema { w.Header().Set("Content-Security-Policy", "upgrade-insecure-requests") } // TODO: WIP. Refactor this to eliminate the unnecessary query // TODO: Better take proxies into consideration if !Config.DisableIP { var host string // TODO: Prefer Cf-Connecting-Ip header, fewer shenanigans if Site.HasProxy { // TODO: Check the right-most IP, might get tricky with multiple proxies, maybe have a setting for the number of hops we jump through xForwardedFor := r.Header.Get("X-Forwarded-For") if xForwardedFor != "" { forwardedFor := strings.Split(xForwardedFor, ",") // TODO: Check if this is a valid IP Address, reject if not host = forwardedFor[len(forwardedFor)-1] } } if host == "" { var e error host, _, e = net.SplitHostPort(r.RemoteAddr) if e != nil { _ = PreError("Bad IP", w, r) return *usercpy, false } } if !Config.DisableLastIP && usercpy.Loggedin && host != usercpy.GetIP() { mon := time.Now().Month() e := usercpy.UpdateIP(strconv.Itoa(int(mon)) + "-" + host) if e != nil { _ = InternalError(e, w, r) return *usercpy, false } } usercpy.LastIP = host } return *usercpy, true } func UploadAvatar(w http.ResponseWriter, r *http.Request, u *User, tuid int) (ext string, ferr RouteError) { // We don't want multiple files // TODO: Are we doing this correctly? filenameMap := make(map[string]bool) for _, fheaders := range r.MultipartForm.File { for _, hdr := range fheaders { if hdr.Filename == "" { continue } filenameMap[hdr.Filename] = true } } if len(filenameMap) > 1 { return "", LocalError("You may only upload one avatar", w, r, u) } for _, fheaders := range r.MultipartForm.File { for _, hdr := range fheaders { if hdr.Filename == "" { continue } inFile, err := hdr.Open() if err != nil { return "", LocalError("Upload failed", w, r, u) } defer inFile.Close() if ext == "" { extarr := strings.Split(hdr.Filename, ".") if len(extarr) < 2 { return "", LocalError("Bad file", w, r, u) } ext = extarr[len(extarr)-1] // TODO: Can we do this without a regex? reg, err := regexp.Compile("[^A-Za-z0-9]+") if err != nil { return "", LocalError("Bad file extension", w, r, u) } ext = reg.ReplaceAllString(ext, "") ext = strings.ToLower(ext) if !ImageFileExts.Contains(ext) { return "", LocalError("You can only use an image for your avatar", w, r, u) } } // TODO: Centralise this string, so we don't have to change it in two different places when it changes outFile, err := os.Create("./uploads/avatar_" + strconv.Itoa(tuid) + "." + ext) if err != nil { return "", LocalError("Upload failed [File Creation Failed]", w, r, u) } defer outFile.Close() _, err = io.Copy(outFile, inFile) if err != nil { return "", LocalError("Upload failed [Copy Failed]", w, r, u) } } } if ext == "" { return "", LocalError("No file", w, r, u) } return ext, nil } func ChangeAvatar(path string, w http.ResponseWriter, r *http.Request, u *User) RouteError { e := u.ChangeAvatar(path) if e != nil { return InternalError(e, w, r) } // Clean up the old avatar data, so we don't end up with too many dead files in /uploads/ if len(u.RawAvatar) > 2 { if u.RawAvatar[0] == '.' && u.RawAvatar[1] == '.' { e := os.Remove("./uploads/avatar_" + strconv.Itoa(u.ID) + "_tmp" + u.RawAvatar[1:]) if e != nil && !os.IsNotExist(e) { LogWarning(e) return LocalError("Something went wrong", w, r, u) } e = os.Remove("./uploads/avatar_" + strconv.Itoa(u.ID) + "_w48" + u.RawAvatar[1:]) if e != nil && !os.IsNotExist(e) { LogWarning(e) return LocalError("Something went wrong", w, r, u) } } } return nil } // SuperAdminOnly makes sure that only super admin can access certain critical panel routes func SuperAdminOnly(w http.ResponseWriter, r *http.Request, u *User) RouteError { if !u.IsSuperAdmin { return NoPermissions(w, r, u) } return nil } // AdminOnly makes sure that only admins can access certain panel routes func AdminOnly(w http.ResponseWriter, r *http.Request, u *User) RouteError { if !u.IsAdmin { return NoPermissions(w, r, u) } return nil } // SuperModeOnly makes sure that only super mods or higher can access the panel routes func SuperModOnly(w http.ResponseWriter, r *http.Request, u *User) RouteError { if !u.IsSuperMod { return NoPermissions(w, r, u) } return nil } // MemberOnly makes sure that only logged in users can access this route func MemberOnly(w http.ResponseWriter, r *http.Request, u *User) RouteError { if !u.Loggedin { return LoginRequired(w, r, u) } return nil } // NoBanned stops any banned users from accessing this route func NoBanned(w http.ResponseWriter, r *http.Request, u *User) RouteError { if u.IsBanned { return Banned(w, r, u) } return nil } func ParseForm(w http.ResponseWriter, r *http.Request, u *User) RouteError { if e := r.ParseForm(); e != nil { return LocalError("Bad Form", w, r, u) } return nil } func NoSessionMismatch(w http.ResponseWriter, r *http.Request, u *User) RouteError { if e := r.ParseForm(); e != nil { return LocalError("Bad Form", w, r, u) } if len(u.Session) == 0 { return SecurityError(w, r, u) } // TODO: Try to eliminate some of these allocations sess := []byte(u.Session) if subtle.ConstantTimeCompare([]byte(r.FormValue("session")), sess) != 1 && subtle.ConstantTimeCompare([]byte(r.FormValue("s")), sess) != 1 { return SecurityError(w, r, u) } return nil } func ReqIsJson(r *http.Request) bool { return r.Header.Get("Content-type") == "application/json" } func HandleUploadRoute(w http.ResponseWriter, r *http.Request, u *User, maxFileSize int) RouteError { // TODO: Reuse this code more if r.ContentLength > int64(maxFileSize) { size, unit := ConvertByteUnit(float64(maxFileSize)) return CustomError("Your upload is too big. Your files need to be smaller than "+strconv.Itoa(int(size))+unit+".", http.StatusExpectationFailed, "Error", w, r, nil, u) } r.Body = http.MaxBytesReader(w, r.Body, r.ContentLength) e := r.ParseMultipartForm(int64(Megabyte)) if e != nil { return LocalError("Bad Form", w, r, u) } return nil } func NoUploadSessionMismatch(w http.ResponseWriter, r *http.Request, u *User) RouteError { if len(u.Session) == 0 { return SecurityError(w, r, u) } // TODO: Try to eliminate some of these allocations sess := []byte(u.Session) if subtle.ConstantTimeCompare([]byte(r.FormValue("session")), sess) != 1 && subtle.ConstantTimeCompare([]byte(r.FormValue("s")), sess) != 1 { return SecurityError(w, r, u) } return nil } ================================================ FILE: common/search.go ================================================ package common import ( "database/sql" "errors" "strconv" qgen "github.com/Azareal/Gosora/query_gen" ) var RepliesSearch Searcher type Searcher interface { Query(q string, zones []int) ([]int, error) } // TODO: Implement this // Note: This is slow compared to something like ElasticSearch and very limited type SQLSearcher struct { queryReplies *sql.Stmt queryTopics *sql.Stmt queryRepliesZone *sql.Stmt queryTopicsZone *sql.Stmt //queryZone *sql.Stmt fuzzyZone *sql.Stmt } // TODO: Support things other than MySQL // TODO: Use LIMIT? func NewSQLSearcher(acc *qgen.Accumulator) (*SQLSearcher, error) { if acc.GetAdapter().GetName() != "mysql" { return nil, errors.New("SQLSearcher only supports MySQL at this time") } return &SQLSearcher{ queryReplies: acc.RawPrepare("SELECT tid FROM replies WHERE MATCH(content) AGAINST (? IN BOOLEAN MODE)"), queryTopics: acc.RawPrepare("SELECT tid FROM topics WHERE MATCH(title) AGAINST (? IN BOOLEAN MODE) OR MATCH(content) AGAINST (? IN BOOLEAN MODE)"), queryRepliesZone: acc.RawPrepare("SELECT tid FROM replies WHERE MATCH(content) AGAINST (? IN BOOLEAN MODE) AND tid=?"), queryTopicsZone: acc.RawPrepare("SELECT tid FROM topics WHERE (MATCH(title) AGAINST (? IN BOOLEAN MODE) OR MATCH(content) AGAINST (? IN BOOLEAN MODE)) AND parentID=?"), //queryZone: acc.RawPrepare("SELECT topics.tid FROM topics INNER JOIN replies ON topics.tid = replies.tid WHERE (topics.title=? OR (MATCH(topics.title) AGAINST (? IN BOOLEAN MODE) OR MATCH(topics.content) AGAINST (? IN BOOLEAN MODE) OR MATCH(replies.content) AGAINST (? IN BOOLEAN MODE)) OR topics.content=? OR replies.content=?) AND topics.parentID=?"), fuzzyZone: acc.RawPrepare("SELECT topics.tid FROM topics INNER JOIN replies ON topics.tid = replies.tid WHERE (topics.title LIKE ? OR topics.content LIKE ? OR replies.content LIKE ?) AND topics.parentID=?"), }, acc.FirstError() } func (s *SQLSearcher) queryAll(q string) ([]int, error) { var ids []int run := func(stmt *sql.Stmt, q ...interface{}) error { rows, e := stmt.Query(q...) if e == sql.ErrNoRows { return nil } else if e != nil { return e } defer rows.Close() for rows.Next() { var id int if e := rows.Scan(&id); e != nil { return e } ids = append(ids, id) } return rows.Err() } err := run(s.queryReplies, q) if err != nil { return nil, err } err = run(s.queryTopics, q, q) if err != nil { return nil, err } if len(ids) == 0 { err = sql.ErrNoRows } return ids, err } func (s *SQLSearcher) Query(q string, zones []int) (ids []int, err error) { if len(zones) == 0 { return nil, nil } run := func(rows *sql.Rows, e error) error { /*if e == sql.ErrNoRows { return nil } else */if e != nil { return e } defer rows.Close() for rows.Next() { var id int if e := rows.Scan(&id); e != nil { return e } ids = append(ids, id) } return rows.Err() } if len(zones) == 1 { //err = run(s.queryZone.Query(q, q, q, q, q,q, zones[0])) err = run(s.queryRepliesZone.Query(q, zones[0])) if err != nil { return nil, err } err = run(s.queryTopicsZone.Query(q, q,zones[0])) } else { var zList string for _, zone := range zones { zList += strconv.Itoa(zone) + "," } zList = zList[:len(zList)-1] acc := qgen.NewAcc() /*stmt := acc.RawPrepare("SELECT topics.tid FROM topics INNER JOIN replies ON topics.tid = replies.tid WHERE (MATCH(topics.title) AGAINST (? IN BOOLEAN MODE) OR MATCH(topics.content) AGAINST (? IN BOOLEAN MODE) OR MATCH(replies.content) AGAINST (? IN BOOLEAN MODE) OR topics.title=? OR topics.content=? OR replies.content=?) AND topics.parentID IN(" + zList + ")") if err = acc.FirstError(); err != nil { return nil, err }*/ // TODO: Cache common IN counts stmt := acc.RawPrepare("SELECT tid FROM topics WHERE (MATCH(topics.title) AGAINST (? IN BOOLEAN MODE) OR MATCH(topics.content) AGAINST (? IN BOOLEAN MODE)) AND parentID IN(" + zList + ")") if err = acc.FirstError(); err != nil { return nil, err } err = run(stmt.Query(q, q)) if err != nil { return nil, err } stmt = acc.RawPrepare("SELECT tid FROM replies WHERE MATCH(replies.content) AGAINST (? IN BOOLEAN MODE) AND tid IN(" + zList + ")") if err = acc.FirstError(); err != nil { return nil, err } err = run(stmt.Query(q)) //err = run(stmt.Query(q, q, q, q, q, q)) } if err != nil { return nil, err } if len(ids) == 0 { err = sql.ErrNoRows } return ids, err } // TODO: Implement this type ElasticSearchSearcher struct { } func NewElasticSearchSearcher() (*ElasticSearchSearcher, error) { return &ElasticSearchSearcher{}, nil } func (s *ElasticSearchSearcher) Query(q string, zones []int) ([]int, error) { return nil, nil } ================================================ FILE: common/settings.go ================================================ package common import ( "database/sql" "errors" "strconv" "strings" "sync/atomic" qgen "github.com/Azareal/Gosora/query_gen" ) var SettingBox atomic.Value // An atomic value pointing to a SettingBox // SettingMap is a map type specifically for holding the various settings admins set to toggle features on and off or to otherwise alter Gosora's behaviour from the Control Panel type SettingMap map[string]interface{} type SettingStore interface { ParseSetting(name, content, typ, constraint string) string BypassGet(name string) (*Setting, error) BypassGetAll(name string) ([]*Setting, error) } type OptionLabel struct { Label string Value int Selected bool } type Setting struct { Name string Content string Type string Constraint string } type SettingStmts struct { getAll *sql.Stmt get *sql.Stmt update *sql.Stmt } var settingStmts SettingStmts func init() { SettingBox.Store(SettingMap(make(map[string]interface{}))) DbInits.Add(func(acc *qgen.Accumulator) error { s := "settings" settingStmts = SettingStmts{ getAll: acc.Select(s).Columns("name,content,type,constraints").Prepare(), get: acc.Select(s).Columns("content,type,constraints").Where("name=?").Prepare(), update: acc.Update(s).Set("content=?").Where("name=?").Prepare(), } return acc.FirstError() }) } func (s *Setting) Copy() (o *Setting) { o = &Setting{Name: ""} *o = *s return o } func LoadSettings() error { sBox := SettingMap(make(map[string]interface{})) settings, err := sBox.BypassGetAll() if err != nil { return err } for _, s := range settings { err = sBox.ParseSetting(s.Name, s.Content, s.Type, s.Constraint) if err != nil { return err } } SettingBox.Store(sBox) return nil } // TODO: Add better support for HTML attributes (html-attribute). E.g. Meta descriptions. func (sBox SettingMap) ParseSetting(name, content, typ, constraint string) (err error) { ssBox := map[string]interface{}(sBox) switch typ { case "bool": ssBox[name] = (content == "1") case "int": ssBox[name], err = strconv.Atoi(content) if err != nil { return errors.New("You were supposed to enter an integer x.x") } case "int64": ssBox[name], err = strconv.ParseInt(content, 10, 64) if err != nil { return errors.New("You were supposed to enter an integer x.x") } case "list": cons := strings.Split(constraint, "-") if len(cons) < 2 { return errors.New("Invalid constraint! The second field wasn't set!") } con1, err := strconv.Atoi(cons[0]) con2, err2 := strconv.Atoi(cons[1]) if err != nil || err2 != nil { return errors.New("Invalid contraint! The constraint field wasn't an integer!") } val, err := strconv.Atoi(content) if err != nil { return errors.New("Only integers are allowed in this setting x.x") } if val < con1 || val > con2 { return errors.New("Only integers between a certain range are allowed in this setting") } ssBox[name] = val default: ssBox[name] = content } return nil } func (sBox SettingMap) BypassGet(name string) (*Setting, error) { s := &Setting{Name: name} err := settingStmts.get.QueryRow(name).Scan(&s.Content, &s.Type, &s.Constraint) return s, err } func (sBox SettingMap) BypassGetAll() (settingList []*Setting, err error) { rows, err := settingStmts.getAll.Query() if err != nil { return nil, err } defer rows.Close() for rows.Next() { s := &Setting{Name: ""} err := rows.Scan(&s.Name, &s.Content, &s.Type, &s.Constraint) if err != nil { return nil, err } settingList = append(settingList, s) } return settingList, rows.Err() } func (sBox SettingMap) Update(name, content string) RouteError { s, err := sBox.BypassGet(name) if err == ErrNoRows { return FromError(err) } else if err != nil { return SysError(err.Error()) } // TODO: Why is this here and not in a common function? if s.Type == "bool" { if content == "on" || content == "1" { content = "1" } else { content = "0" } } err = sBox.ParseSetting(name, content, s.Type, s.Constraint) if err != nil { return FromError(err) } // TODO: Make this a method or function? _, err = settingStmts.update.Exec(content, name) if err != nil { return SysError(err.Error()) } err = LoadSettings() if err != nil { return SysError(err.Error()) } return nil } ================================================ FILE: common/site.go ================================================ package common import ( "encoding/json" "io/ioutil" "log" "net/url" "strconv" "strings" "github.com/pkg/errors" ) // Site holds the basic settings which should be tweaked when setting up a site, we might move them to the settings table at some point var Site = &site{Name: "Magical Fairy Land", Language: "english"} // DbConfig holds the database configuration var DbConfig = &dbConfig{Host: "localhost"} // Config holds the more technical settings var Config = new(config) // Dev holds build flags and other things which should only be modified during developers or to gather additional test data var Dev = new(devConfig) var PluginConfig = map[string]string{} type site struct { ShortName string Name string Email string URL string Host string LocalHost bool // Used internally, do not modify as it will be overwritten Port string PortInt int // Alias for efficiency, do not modify, will be overwritten EnableSsl bool EnableEmails bool HasProxy bool Language string MaxRequestSize int // Alias, do not modify, will be overwritten } type dbConfig struct { // Production database Adapter string Host string Username string Password string Dbname string Port string // Test database. Split this into a separate variable? TestAdapter string TestHost string TestUsername string TestPassword string TestDbname string TestPort string } type config struct { SslPrivkey string SslFullchain string HashAlgo string // Defaults to bcrypt, and in the future, possibly something stronger ConvoKey string MaxRequestSizeStr string MaxRequestSize int UserCache string UserCacheCapacity int TopicCache string TopicCacheCapacity int ReplyCache string ReplyCacheCapacity int SMTPServer string SMTPUsername string SMTPPassword string SMTPPort string SMTPEnableTLS bool Search string DefaultPath string DefaultGroup int // Should be a setting in the database ActivationGroup int // Should be a setting in the database StaffCSS string // ? - Move this into the settings table? Might be better to implement this as Group CSS DefaultForum int // The forum posts go in by default, this used to be covered by the Uncategorised Forum, but we want to replace it with a more robust solution. Make this a setting? MinifyTemplates bool BuildSlugs bool // TODO: Make this a setting? PrimaryServer bool ServerCount int LastIPCutoff int // Currently just -1, non--1, but will accept the number of months a user's last IP should be retained for in the future before being purged. Please note that the other two cutoffs below operate off the numbers of days instead. PostIPCutoff int PollIPCutoff int LogPruneCutoff int //SelfDeleteTruncCutoff int // Personal data is stripped from the mod action rows only leaving the TID and the action for later investigation. DisableIP bool DisableLastIP bool DisablePostIP bool DisablePollIP bool DisableRegLog bool DisableLoginLog bool //DisableSelfDeleteLog bool DisableLiveTopicList bool DisableJSAntispam bool //LooseCSP bool LooseHost bool LoosePort bool SslSchema bool // Pretend we're using SSL, might be useful if a reverse-proxy terminates SSL in-front of Gosora DisableServerPush bool EnableCDNPush bool DisableNoavatarRange bool DisableDefaultNoavatar bool DisableAnalytics bool RefNoTrack bool RefNoRef bool NoEmbed bool ExtraCSPOrigins string StaticResBase string // /s/ //DynStaticResBase string AvatarResBase string // /uploads/ Noavatar string // ? - Move this into the settings table? ItemsPerPage int // ? - Move this into the settings table? MaxTopicTitleLength int MaxUsernameLength int ReadTimeout int WriteTimeout int IdleTimeout int LogDir string DisableSuspLog bool DisableBadRouteLog bool DisableStdout bool DisableStderr bool } type devConfig struct { DebugMode bool SuperDebug bool TemplateDebug bool Profiling bool TestDB bool NoFsnotify bool // Super Experimental! FullReqLog bool ExtraTmpls string // Experimental flag for adding compiled templates, we'll likely replace this with a better mechanism //QuicPort int // Experimental! //ExpFix1 bool // unlisted setting, experimental fix for http/1.1 conn hangs LogLongTick bool // unlisted setting LogNewLongRoute bool // unlisted setting Log4thLongRoute bool // unlisted setting HourDBTimeout bool // unlisted setting } // configHolder is purely for having a big struct to unmarshal data into type configHolder struct { Site *site Config *config Database *dbConfig Dev *devConfig Plugin map[string]string } func LoadConfig() error { data, err := ioutil.ReadFile("./config/config.json") if err != nil { return err } var config configHolder err = json.Unmarshal(data, &config) if err != nil { return err } Site = config.Site Config = config.Config DbConfig = config.Database Dev = config.Dev PluginConfig = config.Plugin return nil } var noavatarCache200 []string var noavatarCache48 []string /*var noavatarCache200Jpg []string var noavatarCache48Jpg []string var noavatarCache200Avif []string var noavatarCache48Avif []string*/ func ProcessConfig() (err error) { // Strip these unnecessary bits, if we find them. Site.URL = strings.TrimPrefix(Site.URL, "http://") Site.URL = strings.TrimPrefix(Site.URL, "https://") Site.Host = Site.URL Site.LocalHost = Site.Host == "localhost" || Site.Host == "127.0.0.1" || Site.Host == "::1" Site.PortInt, err = strconv.Atoi(Site.Port) if err != nil { return errors.New("The port must be a valid integer") } if Site.PortInt != 80 && Site.PortInt != 443 { Site.URL = strings.TrimSuffix(Site.URL, "/") Site.URL = strings.TrimSuffix(Site.URL, "\\") Site.URL = strings.TrimSuffix(Site.URL, ":") Site.URL = Site.URL + ":" + Site.Port } uurl, err := url.Parse(Site.URL) if err != nil { return errors.Wrap(err, "failed to parse Site.URL: ") } if Site.EnableSsl { Config.SslSchema = Site.EnableSsl } if Config.DefaultPath == "" { Config.DefaultPath = "/topics/" } // TODO: Bump the size of max request size up, if it's too low Config.MaxRequestSize, err = strconv.Atoi(Config.MaxRequestSizeStr) if err != nil { reqSizeStr := Config.MaxRequestSizeStr if len(reqSizeStr) < 3 { return errors.New("Invalid unit for MaxRequestSizeStr") } quantity, err := strconv.Atoi(reqSizeStr[:len(reqSizeStr)-2]) if err != nil { return errors.New("Unable to convert quantity to integer in MaxRequestSizeStr, found " + reqSizeStr[:len(reqSizeStr)-2]) } unit := reqSizeStr[len(reqSizeStr)-2:] // TODO: Make it a named error just in case new errors are added in here in the future Config.MaxRequestSize, err = FriendlyUnitToBytes(quantity, unit) if err != nil { return errors.New("Unable to recognise unit for MaxRequestSizeStr, found " + unit) } } if Dev.DebugMode { log.Print("Set MaxRequestSize to ", Config.MaxRequestSize) } if Config.MaxRequestSize <= 0 { log.Fatal("MaxRequestSize should not be zero or below") } Site.MaxRequestSize = Config.MaxRequestSize local := func(h string) bool { return h == "localhost" || h == "127.0.0.1" || h == "::1" || h == Site.URL } uurl, err = url.Parse(Config.StaticResBase) if err != nil { return errors.Wrap(err, "failed to parse Config.StaticResBase: ") } host := uurl.Hostname() if !local(host) { Config.ExtraCSPOrigins += " " + host Config.RefNoRef = true // Avoid leaking origin data to the CDN } if Config.StaticResBase != "" { StaticFiles.Prefix = Config.StaticResBase } uurl, err = url.Parse(Config.AvatarResBase) if err != nil { return errors.Wrap(err, "failed to parse Config.AvatarResBase: ") } host2 := uurl.Hostname() if host != host2 && !local(host) { Config.ExtraCSPOrigins += " " + host Config.RefNoRef = true // Avoid leaking origin data to the CDN } if Config.AvatarResBase == "" { Config.AvatarResBase = "/uploads/" } if !Config.DisableDefaultNoavatar { cap := 11 noavatarCache200 = make([]string, cap) noavatarCache48 = make([]string, cap) /*noavatarCache200Jpg = make([]string, cap) noavatarCache48Jpg = make([]string, cap) noavatarCache200Avif = make([]string, cap) noavatarCache48Avif = make([]string, cap)*/ for i := 0; i < cap; i++ { noavatarCache200[i] = StaticFiles.Prefix + "n" + strconv.Itoa(i) + "-" + strconv.Itoa(200) + ".png?i=0" noavatarCache48[i] = StaticFiles.Prefix + "n" + strconv.Itoa(i) + "-" + strconv.Itoa(48) + ".png?i=0" /*noavatarCache200Jpg[i] = StaticFiles.Prefix + "n" + strconv.Itoa(i) + "-" + strconv.Itoa(200) + ".jpg?i=0" noavatarCache48Jpg[i] = StaticFiles.Prefix + "n" + strconv.Itoa(i) + "-" + strconv.Itoa(48) + ".jpg?i=0" noavatarCache200Avif[i] = StaticFiles.Prefix + "n" + strconv.Itoa(i) + "-" + strconv.Itoa(200) + ".avif?i=0" noavatarCache48Avif[i] = StaticFiles.Prefix + "n" + strconv.Itoa(i) + "-" + strconv.Itoa(48) + ".avif?i=0"*/ } } Config.Noavatar = strings.Replace(Config.Noavatar, "{site_url}", Site.URL, -1) guestAvatar = GuestAvatar{buildNoavatar(0, 200), buildNoavatar(0, 48)} if Config.DisableIP { Config.DisableLastIP = true Config.DisablePostIP = true Config.DisablePollIP = true } if Config.PostIPCutoff == 0 { Config.PostIPCutoff = 90 // Default cutoff } if Config.LogPruneCutoff == 0 { Config.LogPruneCutoff = 180 // Default cutoff } if Config.LastIPCutoff == 0 { Config.LastIPCutoff = 3 // Default cutoff } if Config.LastIPCutoff > 12 { Config.LastIPCutoff = 12 } if Config.PollIPCutoff == 0 { Config.PollIPCutoff = 90 // Default cutoff } if Config.NoEmbed { DefaultParseSettings.NoEmbed = true } // ? Find a way of making these unlimited if zero? It might rule out some optimisations, waste memory, and break layouts if Config.MaxTopicTitleLength == 0 { Config.MaxTopicTitleLength = 100 } if Config.MaxUsernameLength == 0 { Config.MaxUsernameLength = 100 } GuestUser.Avatar, GuestUser.MicroAvatar = BuildAvatar(0, "") if Config.HashAlgo != "" { // TODO: Set the alternate hash algo, e.g. argon2 } if Config.LogDir == "" { Config.LogDir = "./logs/" } // We need this in here rather than verifyConfig as switchToTestDB() currently overwrites the values it verifies if DbConfig.TestDbname == DbConfig.Dbname { return errors.New("Your test database can't have the same name as your production database") } if Dev.TestDB { SwitchToTestDB() } return nil } func VerifyConfig() (err error) { switch { case !Forums.Exists(Config.DefaultForum): err = errors.New("Invalid default forum") case Config.ServerCount < 1: err = errors.New("You can't have less than one server") case Config.MaxTopicTitleLength > 100: err = errors.New("The max topic title length cannot be over 100 as that's unable to fit in the database row") case Config.MaxUsernameLength > 100: err = errors.New("The max username length cannot be over 100 as that's unable to fit in the database row") } return err } func SwitchToTestDB() { DbConfig.Host = DbConfig.TestHost DbConfig.Username = DbConfig.TestUsername DbConfig.Password = DbConfig.TestPassword DbConfig.Dbname = DbConfig.TestDbname DbConfig.Port = DbConfig.TestPort } ================================================ FILE: common/statistics.go ================================================ package common // EXPERIMENTAL import ( "errors" ) var StatStore StatStoreInt type StatStoreInt interface { LookupInt(name string, duration int, unit string) (int, error) } type DefaultStatStore struct { } func NewDefaultStatStore() *DefaultStatStore { return &DefaultStatStore{} } func (s *DefaultStatStore) LookupInt(name string, duration int, unit string) (int, error) { switch name { case "postCount": return s.countTable("replies", duration, unit) } return 0, errors.New("The requested stat doesn't exist") } func (s *DefaultStatStore) countTable(table string, duration int, unit string) (stat int, err error) { /*counter := qgen.NewAcc().Count("replies").DateCutoff("createdAt", 1, "day").Prepare() if acc.FirstError() != nil { return 0, acc.FirstError() } err := counter.QueryRow().Scan(&stat)*/ return stat, err } //stmts.todaysPostCount, err = db.Prepare("select count(*) from replies where createdAt BETWEEN (utc_timestamp() - interval 1 day) and utc_timestamp()") ================================================ FILE: common/subscription.go ================================================ package common import ( "database/sql" qgen "github.com/Azareal/Gosora/query_gen" ) var Subscriptions SubscriptionStore // ? Should we have a subscription store for each zone? topic, forum, etc? type SubscriptionStore interface { Add(uid, elementID int, elementType string) error Delete(uid, targetID int, targetType string) error DeleteResource(targetID int, targetType string) error } type DefaultSubscriptionStore struct { add *sql.Stmt delete *sql.Stmt deleteResource *sql.Stmt } func NewDefaultSubscriptionStore() (*DefaultSubscriptionStore, error) { acc := qgen.NewAcc() ast := "activity_subscriptions" return &DefaultSubscriptionStore{ add: acc.Insert(ast).Columns("user,targetID,targetType,level").Fields("?,?,?,2").Prepare(), delete: acc.Delete(ast).Where("user=? AND targetID=? AND targetType=?").Prepare(), deleteResource: acc.Delete(ast).Where("targetID=? AND targetType=?").Prepare(), }, acc.FirstError() } func (s *DefaultSubscriptionStore) Add(uid, elementID int, elementType string) error { _, err := s.add.Exec(uid, elementID, elementType) return err } // TODO: Add a primary key to the activity subscriptions table func (s *DefaultSubscriptionStore) Delete(uid, targetID int, targetType string) error { _, err := s.delete.Exec(uid, targetID, targetType) return err } func (s *DefaultSubscriptionStore) DeleteResource(targetID int, targetType string) error { _, err := s.deleteResource.Exec(targetID, targetType) return err } ================================================ FILE: common/tasks.go ================================================ /* * * Gosora Task System * Copyright Azareal 2017 - 2020 * */ package common import ( "database/sql" "log" "time" qgen "github.com/Azareal/Gosora/query_gen" ) type TaskStmts struct { getExpiredScheduledGroups *sql.Stmt getSync *sql.Stmt } var Tasks *ScheduledTasks type TaskSet interface { Add(func() error) GetList() []func() error Run() error Count() int } type DefaultTaskSet struct { Tasks []func() error } func (s *DefaultTaskSet) Add(task func() error) { s.Tasks = append(s.Tasks, task) } func (s *DefaultTaskSet) GetList() []func() error { return s.Tasks } func (s *DefaultTaskSet) Run() error { for _, task := range s.Tasks { if e := task(); e != nil { return e } } return nil } func (s *DefaultTaskSet) Count() int { return len(s.Tasks) } type ScheduledTasks struct { HalfSec TaskSet Sec TaskSet FifteenMin TaskSet Hour TaskSet Day TaskSet Shutdown TaskSet } func NewScheduledTasks() *ScheduledTasks { return &ScheduledTasks{ HalfSec: &DefaultTaskSet{}, Sec: &DefaultTaskSet{}, FifteenMin: &DefaultTaskSet{}, Hour: &DefaultTaskSet{}, Day: &DefaultTaskSet{}, Shutdown: &DefaultTaskSet{}, } } /*var ScheduledHalfSecondTasks []func() error var ScheduledSecondTasks []func() error var ScheduledFifteenMinuteTasks []func() error var ScheduledHourTasks []func() error var ScheduledDayTasks []func() error var ShutdownTasks []func() error*/ var taskStmts TaskStmts var lastSync time.Time // TODO: Add a TaskInits.Add func init() { lastSync = time.Now() DbInits.Add(func(acc *qgen.Accumulator) error { taskStmts = TaskStmts{ getExpiredScheduledGroups: acc.Select("users_groups_scheduler").Columns("uid").Where("UTC_TIMESTAMP() > revert_at AND temporary = 1").Prepare(), getSync: acc.Select("sync").Columns("last_update").Prepare(), } return acc.FirstError() }) } // AddScheduledHalfSecondTask is not concurrency safe /*func AddScheduledHalfSecondTask(task func() error) { ScheduledHalfSecondTasks = append(ScheduledHalfSecondTasks, task) } // AddScheduledSecondTask is not concurrency safe func AddScheduledSecondTask(task func() error) { ScheduledSecondTasks = append(ScheduledSecondTasks, task) } // AddScheduledFifteenMinuteTask is not concurrency safe func AddScheduledFifteenMinuteTask(task func() error) { ScheduledFifteenMinuteTasks = append(ScheduledFifteenMinuteTasks, task) } // AddScheduledHourTask is not concurrency safe func AddScheduledHourTask(task func() error) { ScheduledHourTasks = append(ScheduledHourTasks, task) } // AddScheduledDayTask is not concurrency safe func AddScheduledDayTask(task func() error) { ScheduledDayTasks = append(ScheduledDayTasks, task) } // AddShutdownTask is not concurrency safe func AddShutdownTask(task func() error) { ShutdownTasks = append(ShutdownTasks, task) } // ScheduledHalfSecondTaskCount is not concurrency safe func ScheduledHalfSecondTaskCount() int { return len(ScheduledHalfSecondTasks) } // ScheduledSecondTaskCount is not concurrency safe func ScheduledSecondTaskCount() int { return len(ScheduledSecondTasks) } // ScheduledFifteenMinuteTaskCount is not concurrency safe func ScheduledFifteenMinuteTaskCount() int { return len(ScheduledFifteenMinuteTasks) } // ScheduledHourTaskCount is not concurrency safe func ScheduledHourTaskCount() int { return len(ScheduledHourTasks) } // ScheduledDayTaskCount is not concurrency safe func ScheduledDayTaskCount() int { return len(ScheduledDayTasks) } // ShutdownTaskCount is not concurrency safe func ShutdownTaskCount() int { return len(ShutdownTasks) }*/ // TODO: Use AddScheduledSecondTask func HandleExpiredScheduledGroups() error { rows, e := taskStmts.getExpiredScheduledGroups.Query() if e != nil { return e } defer rows.Close() var uid int for rows.Next() { if e := rows.Scan(&uid); e != nil { return e } // Sneaky way of initialising a *User, please use the methods on the UserStore instead user := BlankUser() user.ID = uid if e = user.RevertGroupUpdate(); e != nil { return e } } return rows.Err() } // TODO: Use AddScheduledSecondTask // TODO: Be a little more granular with the synchronisation // TODO: Synchronise more things // TODO: Does this even work? func HandleServerSync() error { // We don't want to run any unnecessary queries when there is nothing to synchronise if Config.ServerCount == 1 { return nil } var lastUpdate time.Time e := taskStmts.getSync.QueryRow().Scan(&lastUpdate) if e != nil { return e } if lastUpdate.After(lastSync) { if e = Forums.LoadForums(); e != nil { log.Print("Unable to reload the forums") return e } // TODO: Resync the groups // TODO: Resync the permissions if e = LoadSettings(); e != nil { log.Print("Unable to reload the settings") return e } if e = WordFilters.ReloadAll(); e != nil { log.Print("Unable to reload the word filters") return e } } return nil } ================================================ FILE: common/template_init.go ================================================ package common import ( "fmt" "html/template" "io" "io/ioutil" "log" "path/filepath" "runtime" "strconv" "strings" "sync" "time" "github.com/Azareal/Gosora/common/alerts" p "github.com/Azareal/Gosora/common/phrases" tmpl "github.com/Azareal/Gosora/common/templates" qgen "github.com/Azareal/Gosora/query_gen" "github.com/Azareal/Gosora/uutils" ) var Ctemplates []string // TODO: Use this to filter out top level templates we don't need var DefaultTemplates = template.New("") var DefaultTemplateFuncMap map[string]interface{} //var Templates = template.New("") var PrebuildTmplList []func(User, *Header) CTmpl func skipCTmpl(key string) bool { for _, tmpl := range Ctemplates { if strings.HasSuffix(key, "/"+tmpl+".html") { return true } } return false } type CTmpl struct { Name string Filename string Path string StructName string Data interface{} Imports []string } func genIntTmpl(name string) func(pi interface{}, w io.Writer) error { return func(pi interface{}, w io.Writer) error { theme := Themes[DefaultThemeBox.Load().(string)] mapping, ok := theme.TemplatesMap[name] if !ok { mapping = name } return DefaultTemplates.ExecuteTemplate(w, mapping+".html", pi) } } // TODO: Refactor the template trees to not need these // nolint var Template_topic_handle = genIntTmpl("topic") var Template_topic_guest_handle = Template_topic_handle var Template_topic_member_handle = Template_topic_handle var Template_topic_alt_handle = genIntTmpl("topic") var Template_topic_alt_guest_handle = Template_topic_alt_handle var Template_topic_alt_member_handle = Template_topic_alt_handle // nolint var Template_topics_handle = genIntTmpl("topics") var Template_topics_guest_handle = Template_topics_handle var Template_topics_member_handle = Template_topics_handle // nolint var Template_forum_handle = genIntTmpl("forum") var Template_forum_guest_handle = Template_forum_handle var Template_forum_member_handle = Template_forum_handle // nolint var Template_forums_handle = genIntTmpl("forums") var Template_forums_guest_handle = Template_forums_handle var Template_forums_member_handle = Template_forums_handle // nolint var Template_profile_handle = genIntTmpl("profile") var Template_profile_guest_handle = Template_profile_handle var Template_profile_member_handle = Template_profile_handle // nolint var Template_create_topic_handle = genIntTmpl("create_topic") var Template_login_handle = genIntTmpl("login") var Template_register_handle = genIntTmpl("register") var Template_error_handle = genIntTmpl("error") var Template_ip_search_handle = genIntTmpl("ip_search") var Template_account_handle = genIntTmpl("account") func tmplInitUsers() (*User, *User, *User) { avatar, microAvatar := BuildAvatar(62, "") u := User{62, BuildProfileURL("fake-user", 62), "Fake User", "compiler@localhost", 0, false, false, false, false, false, false, GuestPerms, make(map[string]bool), "", false, "", avatar, microAvatar, "", "", 0, 0, 0, 0, StartTime, "0.0.0.0.0", 0, 0, nil, UserPrivacy{}} // TODO: Do a more accurate level calculation for this? avatar, microAvatar = BuildAvatar(1, "") u2 := User{1, BuildProfileURL("admin-alice", 1), "Admin Alice", "alice@localhost", 1, true, true, true, true, false, false, AllPerms, make(map[string]bool), "", true, "", avatar, microAvatar, "", "", 58, 1000, 0, 1000, StartTime, "127.0.0.1", 0, 0, nil, UserPrivacy{}} avatar, microAvatar = BuildAvatar(2, "") u3 := User{2, BuildProfileURL("admin-fred", 62), "Admin Fred", "fred@localhost", 1, true, true, true, true, false, false, AllPerms, make(map[string]bool), "", true, "", avatar, microAvatar, "", "", 42, 900, 0, 900, StartTime, "::1", 0, 0, nil, UserPrivacy{}} return &u, &u2, &u3 } func tmplInitHeaders(u, u2, u3 *User) (*Header, *Header, *Header) { header := &Header{ Site: Site, Settings: SettingBox.Load().(SettingMap), //Themes: Themes, ThemesSlice: ThemesSlice, Theme: Themes[DefaultThemeBox.Load().(string)], CurrentUser: u, NoticeList: []string{"test"}, Stylesheets: []HScript{{"panel.css", ""}}, Scripts: []HScript{{"whatever.js", ""}}, PreScriptsAsync: []HScript{{"whatever.js", ""}}, ScriptsAsync: []HScript{{"whatever.js", ""}}, Widgets: PageWidgets{ LeftSidebar: template.HTML("lalala"), }, } buildHeader := func(u *User) *Header { head := &Header{Site: Site} *head = *header head.CurrentUser = u return head } return header, buildHeader(u2), buildHeader(u3) } type TmplLoggedin struct { Stub string Guest string Member string } type nobreak interface{} type TItem struct { Expects string ExpectsInt interface{} LoggedIn bool } type TItemHold map[string]TItem func (h TItemHold) Add(name, expects string, expectsInt interface{}) { h[name] = TItem{expects, expectsInt, true} } func (h TItemHold) AddStd(name, expects string, expectsInt interface{}) { h[name] = TItem{expects, expectsInt, false} } // ? - Add template hooks? func CompileTemplates() error { log.Print("Compiling the templates") // TODO: Implement per-theme template overrides here too overriden := make(map[string]map[string]bool) for _, th := range Themes { overriden[th.Name] = make(map[string]bool) log.Printf("th.OverridenTemplates: %+v\n", th.OverridenTemplates) for _, override := range th.OverridenTemplates { overriden[th.Name][override] = true } } log.Printf("overriden: %+v\n", overriden) config := tmpl.CTemplateConfig{ Minify: Config.MinifyTemplates, Debug: Dev.DebugMode, SuperDebug: Dev.TemplateDebug, DockToID: DockToID, } c := tmpl.NewCTemplateSet("normal", "./logs/") c.SetConfig(config) c.SetBaseImportMap(map[string]string{ "io": "io", "github.com/Azareal/Gosora/common": "c github.com/Azareal/Gosora/common", }) c.SetBuildTags("!no_templategen") c.SetOverrideTrack(overriden) c.SetPerThemeTmpls(make(map[string]bool)) log.Print("Compiling the default templates") var wg sync.WaitGroup if err := compileTemplates(&wg, c, ""); err != nil { return err } oroots := c.GetOverridenRoots() log.Printf("oroots: %+v\n", oroots) log.Print("Compiling the per-theme templates") for th, tmpls := range oroots { c.ResetLogs("normal-" + th) c.SetThemeName(th) c.SetPerThemeTmpls(tmpls) log.Print("th: ", th) log.Printf("perThemeTmpls: %+v\n", tmpls) err := compileTemplates(&wg, c, th) if err != nil { return err } } writeTemplateList(c, &wg, "./") return nil } func compileCommons(c *tmpl.CTemplateSet, head, head2 *Header, forumList []Forum, o TItemHold) error { // TODO: Add support for interface{}s _, user2, user3 := tmplInitUsers() now := time.Now() // Convienience function to save a line here and there htitle := func(name string) *Header { head.Title = name return head } /*htitle2 := func(name string) *Header { head2.Title = name return head2 }*/ var topicsList []TopicsRowMut topic := Topic{1, "/topic/topic-title.1", "Topic Title", "The topic content.", 1, false, false, now, now, user3.ID, 1, 1, "", "::1", 1, 0, 1, 1, 1, "classname", 0, "", nil} topicsList = append(topicsList, TopicsRowMut{&TopicsRow{topic, 1, user2, "", 0, user3, "General", "/forum/general.2"}, false}) topicListPage := TopicListPage{htitle("Topic List"), topicsList, forumList, Config.DefaultForum, TopicListSort{"lastupdated", false}, []int{1}, QuickTools{false, false, false}, Paginator{[]int{1}, 1, 1}} o.Add("topics", "c.TopicListPage", topicListPage) o.Add("topics_mini", "c.TopicListPage", topicListPage) forumItem := BlankForum(1, "general-forum.1", "General Forum", "Where the general stuff happens", true, "all", 0, "", 0) forumPage := ForumPage{htitle("General Forum"), topicsList, forumItem, false, false, Paginator{[]int{1}, 1, 1}} o.Add("forum", "c.ForumPage", forumPage) o.Add("forums", "c.ForumsPage", ForumsPage{htitle("Forum List"), forumList}) poll := Poll{ID: 1, Type: 0, Options: map[int]string{0: "Nothing", 1: "Something"}, Results: map[int]int{0: 5, 1: 2}, QuickOptions: []PollOption{ {0, "Nothing"}, {1, "Something"}, }, VoteCount: 7} avatar, microAvatar := BuildAvatar(62, "") miniAttach := []*MiniAttachment{{Path: "/"}} tu := TopicUser{1, "blah", "Blah", "Hey there!", 0, false, false, now, now, 1, 1, 0, "", "127.0.0.1", 1, 0, 1, 0, "classname", poll.ID, "weird-data", BuildProfileURL("fake-user", 62), "Fake User", Config.DefaultGroup, avatar, microAvatar, 0, "", "", "", 58, false, miniAttach, nil, false} var replyList []*ReplyUser reply := Reply{1, 1, "Yo!", 1 /*, Config.DefaultGroup*/, now, 0, 0, 1, "::1", true, 1, 1, ""} ru := &ReplyUser{ClassName: "", Reply: reply, CreatedByName: "Alice", Avatar: avatar, Group: Config.DefaultGroup, Level: 0, Attachments: miniAttach} _, err := ru.Init(user2) if err != nil { return err } replyList = append(replyList, ru) tpage := TopicPage{htitle("Topic Name"), replyList, tu, &Forum{ID: 1, Name: "Hahaha"}, &poll, Paginator{[]int{1}, 1, 1}} tpage.Forum.Link = BuildForumURL(NameToSlug(tpage.Forum.Name), tpage.Forum.ID) o.Add("topic", "c.TopicPage", tpage) o.Add("topic_mini", "c.TopicPage", tpage) o.Add("topic_alt", "c.TopicPage", tpage) o.Add("topic_alt_mini", "c.TopicPage", tpage) return nil } func compileTemplates(wg *sync.WaitGroup, c *tmpl.CTemplateSet, themeName string) error { // Schemas to train the template compiler on what to expect // TODO: Add support for interface{}s user, user2, user3 := tmplInitUsers() header, header2, _ := tmplInitHeaders(user, user2, user3) now := time.Now() /*poll := Poll{ID: 1, Type: 0, Options: map[int]string{0: "Nothing", 1: "Something"}, Results: map[int]int{0: 5, 1: 2}, QuickOptions: []PollOption{ PollOption{0, "Nothing"}, PollOption{1, "Something"}, }, VoteCount: 7}*/ //avatar, microAvatar := BuildAvatar(62, "") miniAttach := []*MiniAttachment{{Path: "/"}} var replyList []*ReplyUser //topic := TopicUser{1, "blah", "Blah", "Hey there!", 0, false, false, now, now, 1, 1, 0, "", "127.0.0.1", 1, 0, 1, 0, "classname", poll.ID, "weird-data", BuildProfileURL("fake-user", 62), "Fake User", Config.DefaultGroup, avatar, microAvatar, 0, "", "", "", "", "", 58, false, miniAttach, nil} // TODO: Do we want the UID on this to be 0? //avatar, microAvatar = BuildAvatar(0, "") reply := Reply{1, 1, "Yo!", 1 /*, Config.DefaultGroup*/, now, 0, 0, 1, "::1", true, 1, 1, ""} ru := &ReplyUser{ClassName: "", Reply: reply, CreatedByName: "Alice", Avatar: "", Group: Config.DefaultGroup, Level: 0, Attachments: miniAttach} _, err := ru.Init(user) if err != nil { return err } replyList = append(replyList, ru) forum := BlankForum(1, "/forum/d.1", "d", "d desc", true, "", 0, "", 1) forum.LastTopic = BlankTopic() forum.LastReplyer = BlankUser() forumList := []Forum{*forum} // Convienience function to save a line here and there htitle := func(name string) *Header { header.Title = name return header } t := TItemHold(make(map[string]TItem)) err = compileCommons(c, header, header2, forumList, t) if err != nil { return err } ppage := ProfilePage{htitle("User 526"), replyList, *user, 0, 0, false, false, false, false} // TODO: Use the score from user to generate the currentScore and nextScore t.Add("profile", "c.ProfilePage", ppage) var topicsList []TopicsRowMut topic := Topic{1, "topic-title", "Topic Title", "The topic content.", 1, false, false, now, now, user3.ID, 1, 1, "", "::1", 1, 0, 1, 1, 1, "classname", 0, "", nil} topicsList = append(topicsList, TopicsRowMut{&TopicsRow{topic, 0, user2, "", 0, user3, "General", "/forum/general.2"}, false}) topicListPage := TopicListPage{htitle("Topic List"), topicsList, forumList, Config.DefaultForum, TopicListSort{"lastupdated", false}, []int{1}, QuickTools{false, false, false}, Paginator{[]int{1}, 1, 1}} forumItem := BlankForum(1, "general-forum.1", "General Forum", "Where the general stuff happens", true, "all", 0, "", 0) forumPage := ForumPage{htitle("General Forum"), topicsList, forumItem, false, false, Paginator{[]int{1}, 1, 1}} // Experimental! for _, tmpl := range strings.Split(Dev.ExtraTmpls, ",") { sp := strings.Split(tmpl, ":") if len(sp) < 2 { continue } typ := "0" if len(sp) == 3 { typ = sp[2] } var pi interface{} switch sp[1] { case "c.TopicListPage": pi = topicListPage case "c.ForumPage": pi = forumPage case "c.ProfilePage": pi = ppage case "c.Page": pi = Page{htitle("Something"), tList, nil} default: continue } if typ == "1" { t.Add(sp[0], sp[1], pi) } else { t.AddStd(sp[0], sp[1], pi) } } t.AddStd("login", "c.Page", Page{htitle("Login Page"), tList, nil}) t.AddStd("register", "c.RegisterPage", RegisterPage{htitle("Registration Page"), false, "", []RegisterVerify{{true, &RegisterVerifyImageGrid{"What?", []RegisterVerifyImageGridImage{{"something.png"}}}}}}) t.AddStd("error", "c.ErrorPage", ErrorPage{htitle("Error"), "A problem has occurred in the system."}) ipSearchPage := IPSearchPage{htitle("IP Search"), map[int]*User{1: user2}, "::1"} t.AddStd("ip_search", "c.IPSearchPage", ipSearchPage) var inter nobreak accountPage := Account{header, "dashboard", "account_own_edit", inter} t.AddStd("account", "c.Account", accountPage) parti := []*User{user} convo := &Conversation{1, BuildConvoURL(1), user.ID, time.Now(), 0, time.Now()} convoItems := []ConvoViewRow{{&ConversationPost{1, 1, "hey", "", user.ID}, user, "", 4, true}} convoPage := ConvoViewPage{header, convo, convoItems, parti, true, Paginator{[]int{1}, 1, 1}} t.AddStd("convo", "c.ConvoViewPage", convoPage) convos := []*ConversationExtra{{&Conversation{}, []*User{user}}} var cRows []ConvoListRow for _, convo := range convos { cRows = append(cRows, ConvoListRow{convo, convo.Users, false}) } convoListPage := ConvoListPage{header, cRows, Paginator{[]int{1}, 1, 1}} t.AddStd("convos", "c.ConvoListPage", convoListPage) basePage := &BasePanelPage{header, PanelStats{}, "dashboard", ReportForumID, true} t.AddStd("panel", "c.Panel", Panel{basePage, "panel_dashboard_right", "", "panel_dashboard", inter}) ges := []GridElement{{"", "", "", 1, "grid_istat", "", "", ""}} t.AddStd("panel_dashboard", "c.DashGrids", DashGrids{ges, ges}) goVersion := runtime.Version() dbVersion := qgen.Builder.DbVersion() var memStats runtime.MemStats runtime.ReadMemStats(&memStats) debugTasks := DebugPageTasks{0, 0, 0, 0, 0, 0} debugCache := DebugPageCache{1, 1, 1, 2, 2, 2, true} debugDatabase := DebugPageDatabase{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1} debugDisk := DebugPageDisk{1, 1, 1, 1, 1, 1} dpage := PanelDebugPage{basePage, goVersion, dbVersion, "0s", 1, qgen.Builder.GetAdapter().GetName(), 1, 1, 1, debugTasks, memStats, debugCache, debugDatabase, debugDisk} t.AddStd("panel_debug", "c.PanelDebugPage", dpage) //t.AddStd("panel_analytics", "c.PanelAnalytics", Panel{basePage, "panel_dashboard_right","panel_dashboard", inter}) writeTemplate := func(name string, content interface{}) { log.Print("Writing template '" + name + "'") writeTmpl := func(name, content string) { if content == "" { return //log.Fatal("No content body for " + name) } e := writeFile("./tmpl_"+name+".go", content) if e != nil { log.Fatal(e) } } wg.Add(1) go func() { defer EatPanics() tname := themeName if tname != "" { tname = "_" + tname } switch content := content.(type) { case string: writeTmpl(name+tname, content) case TmplLoggedin: writeTmpl(name+tname, content.Stub) writeTmpl(name+tname+"_guest", content.Guest) writeTmpl(name+tname+"_member", content.Member) } wg.Done() }() } // Let plugins register their own templates DebugLog("Registering the templates for the plugins") config := c.GetConfig() config.SkipHandles = true c.SetConfig(config) for _, tmplfunc := range PrebuildTmplList { tmplItem := tmplfunc(*user, header) varList := make(map[string]tmpl.VarItem) compiledTmpl, err := c.Compile(tmplItem.Filename, tmplItem.Path, tmplItem.StructName, tmplItem.Data, varList, tmplItem.Imports...) if err != nil { return err } writeTemplate(tmplItem.Name, compiledTmpl) } log.Print("Writing the templates") for name, titem := range t { log.Print("Writing " + name) varList := make(map[string]tmpl.VarItem) if titem.LoggedIn { stub, guest, member, err := c.CompileByLoggedin(name+".html", "templates/", titem.Expects, titem.ExpectsInt, varList) if err != nil { return err } writeTemplate(name, TmplLoggedin{stub, guest, member}) } else { tmpl, err := c.Compile(name+".html", "templates/", titem.Expects, titem.ExpectsInt, varList) if err != nil { return err } writeTemplate(name, tmpl) } } return nil } // ? - Add template hooks? func CompileJSTemplates() error { log.Print("Compiling the JS templates") // TODO: Implement per-theme template overrides here too overriden := make(map[string]map[string]bool) for _, theme := range Themes { overriden[theme.Name] = make(map[string]bool) log.Printf("theme.OverridenTemplates: %+v\n", theme.OverridenTemplates) for _, override := range theme.OverridenTemplates { overriden[theme.Name][override] = true } } log.Printf("overriden: %+v\n", overriden) config := tmpl.CTemplateConfig{ Minify: Config.MinifyTemplates, Debug: Dev.DebugMode, SuperDebug: Dev.TemplateDebug, SkipHandles: true, SkipTmplPtrMap: true, SkipInitBlock: false, PackageName: "tmpl", DockToID: DockToID, } c := tmpl.NewCTemplateSet("js", "./logs/") c.SetConfig(config) c.SetBuildTags("!no_templategen") c.SetOverrideTrack(overriden) c.SetPerThemeTmpls(make(map[string]bool)) log.Print("Compiling the default templates") var wg sync.WaitGroup err := compileJSTemplates(&wg, c, "") if err != nil { return err } oroots := c.GetOverridenRoots() log.Printf("oroots: %+v\n", oroots) log.Print("Compiling the per-theme templates") for theme, tmpls := range oroots { c.SetThemeName(theme) c.SetPerThemeTmpls(tmpls) log.Print("theme: ", theme) log.Printf("perThemeTmpls: %+v\n", tmpls) err = compileJSTemplates(&wg, c, theme) if err != nil { return err } } dirPrefix := "./tmpl_client/" writeTemplateList(c, &wg, dirPrefix) return nil } func compileJSTemplates(wg *sync.WaitGroup, c *tmpl.CTemplateSet, themeName string) error { user, user2, user3 := tmplInitUsers() header, _, _ := tmplInitHeaders(user, user2, user3) now := time.Now() varList := make(map[string]tmpl.VarItem) c.SetBaseImportMap(map[string]string{ "io": "io", "github.com/Azareal/Gosora/common/alerts": "github.com/Azareal/Gosora/common/alerts", }) // TODO: Check what sort of path is sent exactly and use it here alertItem := alerts.AlertItem{Avatar: "", ASID: 1, Path: "/", Message: "uh oh, something happened"} alertTmpl, err := c.Compile("alert.html", "templates/", "alerts.AlertItem", alertItem, varList) if err != nil { return err } c.SetBaseImportMap(map[string]string{ "io": "io", "github.com/Azareal/Gosora/common": "c github.com/Azareal/Gosora/common", }) // TODO: Fix the import loop so we don't have to use this hack anymore c.SetBuildTags("!no_templategen,tmplgentopic") t := TItemHold(make(map[string]TItem)) topic := Topic{1, "topic-title", "Topic Title", "The topic content.", 1, false, false, now, now, user3.ID, 1, 1, "", "::1", 1, 0, 1, 0, 1, "classname", 1, "", nil} topicsRow := TopicsRowMut{&TopicsRow{topic, 0, user2, "", 0, user3, "General", "/forum/general.2"}, false} t.AddStd("topics_topic", "c.TopicsRowMut", topicsRow) poll := Poll{ID: 1, Type: 0, Options: map[int]string{0: "Nothing", 1: "Something"}, Results: map[int]int{0: 5, 1: 2}, QuickOptions: []PollOption{ {0, "Nothing"}, {1, "Something"}, }, VoteCount: 7} avatar, microAvatar := BuildAvatar(62, "") miniAttach := []*MiniAttachment{{Path: "/"}} tu := TopicUser{1, "blah", "Blah", "Hey there!", 62, false, false, now, now, 1, 1, 0, "", "::1", 1, 0, 1, 0, "classname", poll.ID, "weird-data", BuildProfileURL("fake-user", 62), "Fake User", Config.DefaultGroup, avatar, microAvatar, 0, "", "", "", 58, false, miniAttach, nil, false} var replyList []*ReplyUser // TODO: Do we really want the UID here to be zero? avatar, microAvatar = BuildAvatar(0, "") reply := Reply{1, 1, "Yo!", 1 /*, Config.DefaultGroup*/, now, 0, 0, 1, "::1", true, 1, 1, ""} ru := &ReplyUser{ClassName: "", Reply: reply, CreatedByName: "Alice", Avatar: avatar, Group: Config.DefaultGroup, Level: 0, Attachments: miniAttach} _, err = ru.Init(user) if err != nil { return err } replyList = append(replyList, ru) varList = make(map[string]tmpl.VarItem) header.Title = "Topic Name" tpage := TopicPage{header, replyList, tu, &Forum{ID: 1, Name: "Hahaha"}, &poll, Paginator{[]int{1}, 1, 1}} tpage.Forum.Link = BuildForumURL(NameToSlug(tpage.Forum.Name), tpage.Forum.ID) t.AddStd("topic_posts", "c.TopicPage", tpage) t.AddStd("topic_alt_posts", "c.TopicPage", tpage) itemsPerPage := 25 _, page, lastPage := PageOffset(20, 1, itemsPerPage) pageList := Paginate(page, lastPage, 5) t.AddStd("paginator", "c.Paginator", Paginator{pageList, page, lastPage}) t.AddStd("topic_c_edit_post", "c.TopicCEditPost", TopicCEditPost{ID: 0, Source: "", Ref: ""}) t.AddStd("topic_c_attach_item", "c.TopicCAttachItem", TopicCAttachItem{ID: 1, ImgSrc: "", Path: "", FullPath: ""}) t.AddStd("topic_c_poll_input", "c.TopicCPollInput", TopicCPollInput{Index: 0}) parti := []*User{user} convo := &Conversation{1, BuildConvoURL(1), user.ID, time.Now(), 0, time.Now()} convoItems := []ConvoViewRow{{&ConversationPost{1, 1, "hey", "", user.ID}, user, "", 4, true}} convoPage := ConvoViewPage{header, convo, convoItems, parti, true, Paginator{[]int{1}, 1, 1}} t.AddStd("convo", "c.ConvoViewPage", convoPage) t.AddStd("notice", "string", "nonono") dirPrefix := "./tmpl_client/" writeTemplate := func(name, content string) { log.Print("Writing template '" + name + "'") if content == "" { return //log.Fatal("No content body") } wg.Add(1) go func() { defer EatPanics() tname := themeName if tname != "" { tname = "_" + tname } e := writeFile(dirPrefix+"tmpl_"+name+tname+".jgo", content) if e != nil { log.Fatal(e) } wg.Done() }() } log.Print("Writing the templates") for name, titem := range t { log.Print("Writing " + name) varList := make(map[string]tmpl.VarItem) tmpl, err := c.Compile(name+".html", "templates/", titem.Expects, titem.ExpectsInt, varList) if err != nil { return err } writeTemplate(name, tmpl) } writeTemplate("alert", alertTmpl) /*//writeTemplate("forum", forumTmpl) writeTemplate("topic_posts", topicPostsTmpl) writeTemplate("topic_alt_posts", topicAltPostsTmpl) writeTemplateList(c, &wg, dirPrefix)*/ return nil } var poutlen = len("\n// nolint\nfunc init() {\n") var poutlooplen = len("__frags[0]=a_0[:]\n") func getTemplateList(c *tmpl.CTemplateSet, wg *sync.WaitGroup, prefix string) string { DebugLog("in getTemplateList") //pout := "\n// nolint\nfunc init() {\n" tFragCount := make(map[string]int) bodyMap := make(map[string]string) //map[body]fragmentPrefix //tmplMap := make(map[string]map[string]string) // map[tmpl]map[body]fragmentPrefix tmpCount := 0 var bsb strings.Builder var poutsb strings.Builder poutsb.Grow(poutlen + (poutlooplen * len(c.FragOut))) poutsb.WriteString("\n// nolint\nfunc init() {\n") for _, frag := range c.FragOut { front := frag.TmplName + "_frags[" + strconv.Itoa(frag.Index) + "]" DebugLog("front: ", front) DebugLog("frag.Body: ", frag.Body) /*bodyMap, tok := tmplMap[frag.TmplName] if !tok { tmplMap[frag.TmplName] = make(map[string]string) bodyMap = tmplMap[frag.TmplName] }*/ fp, ok := bodyMap[frag.Body] if !ok { bodyMap[frag.Body] = front //var bits string bsb.Reset() DebugLog("encoding f.Body") for _, char := range []byte(frag.Body) { if char == '\'' { //bits += "'\\" + string(char) + "'," bsb.WriteString("'\\'',") } else if char < 32 { //bits += strconv.Itoa(int(char)) + "," bsb.WriteString(strconv.Itoa(int(char))) bsb.WriteByte(',') } else { //bits += "'" + string(char) + "'," bsb.WriteByte('\'') bsb.WriteString(string(char)) bsb.WriteString("',") } } tmpStr := strconv.Itoa(tmpCount) //"a_" + tmpStr + ":=[...]byte{" + /*bits*/ bsb.String() + "}\n" poutsb.WriteString("a_") poutsb.WriteString(tmpStr) poutsb.WriteString(":=[...]byte{") poutsb.WriteString(bsb.String()) poutsb.WriteString("}\n") //front + "=a_" + tmpStr + "[:]\n" poutsb.WriteString(front) poutsb.WriteString("=a_") poutsb.WriteString(tmpStr) poutsb.WriteString("[:]\n") tmpCount++ //pout += front + "=[]byte(`" + frag.Body + "`)\n" } else { DebugLog("encoding cached index " + fp) poutsb.WriteString(front + "=" + fp + "\n") } _, ok = tFragCount[frag.TmplName] if !ok { tFragCount[frag.TmplName] = 0 } tFragCount[frag.TmplName]++ } //out := "package " + c.GetConfig().PackageName + "\n\n" bsb.Reset() sb := bsb pkgName := c.GetConfig().PackageName sb.Grow(tllenhint + ((looplenhint + 2) + (looplenhint2+2)*len(tFragCount)) + len(pkgName)) sb.WriteString("package ") sb.WriteString(pkgName) sb.WriteString("\n\n") for templateName, count := range tFragCount { //out += "var " + templateName + "_frags = make([][]byte," + strconv.Itoa(count) + ")\n" //out += "var " + templateName + "_frags [" + strconv.Itoa(count) + "][]byte\n" sb.WriteString("var ") sb.WriteString(templateName) sb.WriteString("_frags [") sb.WriteString(strconv.Itoa(count)) sb.WriteString("][]byte\n") } sb.WriteString(poutsb.String()) sb.WriteString("\n\n// nolint\nGetFrag = func(name string) [][]byte {\nswitch(name) {\n") //getterstr := "\n// nolint\nGetFrag = func(name string) [][]byte {\nswitch(name) {\n" for templateName, _ := range tFragCount { //getterstr += "\tcase \"" + templateName + "\":\n" ///getterstr += "\treturn " + templateName + "_frags\n" //getterstr += "\treturn " + templateName + "_frags[:]\n" sb.WriteString("\tcase \"") sb.WriteString(templateName) sb.WriteString("\":\n\treturn ") sb.WriteString(templateName) sb.WriteString("_frags[:]\n") } sb.WriteString("}\nreturn nil\n}\n}\n") //getterstr += "}\nreturn nil\n}\n" //out += pout + "\n" + getterstr + "}\n" return sb.String() } var looplenhint = len("var _frags [][]byte\n") var looplenhint2 = len("\tcase \"\":\n\treturn _frags[:]\n") var tllenhint = len("package \n\n\n// nolint\nGetFrag = func(name string) [][]byte {\nswitch(name) {\nvar _frags [][]byte\n\tcase \"\":\n\treturn _frags[:]\n}\nreturn nil\n}\n\n}\n") func writeTemplateList(c *tmpl.CTemplateSet, wg *sync.WaitGroup, prefix string) { log.Print("Writing template list") wg.Add(1) go func() { defer EatPanics() e := writeFile(prefix+"tmpl_list.go", getTemplateList(c, wg, prefix)) if e != nil { log.Fatal(e) } wg.Done() }() wg.Wait() } func arithToInt64(in interface{}) (o int64) { switch in := in.(type) { case int64: o = in case int32: o = int64(in) case int: o = int64(in) case uint32: o = int64(in) case uint16: o = int64(in) case uint8: o = int64(in) case uint: o = int64(in) } return o } func arithDuoToInt64(left, right interface{}) (leftInt, rightInt int64) { return arithToInt64(left), arithToInt64(right) } func initDefaultTmplFuncMap() { // TODO: Add support for floats fmap := make(map[string]interface{}) fmap["add"] = func(left, right interface{}) interface{} { leftInt, rightInt := arithDuoToInt64(left, right) return leftInt + rightInt } fmap["subtract"] = func(left, right interface{}) interface{} { leftInt, rightInt := arithDuoToInt64(left, right) return leftInt - rightInt } fmap["multiply"] = func(left, right interface{}) interface{} { leftInt, rightInt := arithDuoToInt64(left, right) return leftInt * rightInt } fmap["divide"] = func(left, right interface{}) interface{} { leftInt, rightInt := arithDuoToInt64(left, right) if leftInt == 0 || rightInt == 0 { return 0 } return leftInt / rightInt } fmap["dock"] = func(dock, headerInt interface{}) interface{} { return template.HTML(BuildWidget(dock.(string), headerInt.(*Header))) } fmap["hasWidgets"] = func(dock, headerInt interface{}) interface{} { return HasWidgets(dock.(string), headerInt.(*Header)) } fmap["elapsed"] = func(startedAtInt interface{}) interface{} { //return time.Since(startedAtInt.(time.Time)).String() return time.Duration(uutils.Nanotime() - startedAtInt.(int64)).String() } fmap["lang"] = func(phraseNameInt interface{}) interface{} { phraseName, ok := phraseNameInt.(string) if !ok { panic("phraseNameInt is not a string") } // TODO: Log non-existent phrases? return template.HTML(p.GetTmplPhrase(phraseName)) } // TODO: Implement this in the template generator too fmap["langf"] = func(phraseNameInt interface{}, args ...interface{}) interface{} { phraseName, ok := phraseNameInt.(string) if !ok { panic("phraseNameInt is not a string") } // TODO: Log non-existent phrases? // TODO: Optimise TmplPhrasef so we don't use slow Sprintf there return template.HTML(p.GetTmplPhrasef(phraseName, args...)) } fmap["level"] = func(levelInt interface{}) interface{} { level, ok := levelInt.(int) if !ok { panic("levelInt is not an integer") } return template.HTML(p.GetLevelPhrase(level)) } fmap["bunit"] = func(byteInt interface{}) interface{} { var byteFloat float64 var unit string switch bytes := byteInt.(type) { case int: byteFloat, unit = ConvertByteUnit(float64(bytes)) case int64: byteFloat, unit = ConvertByteUnit(float64(bytes)) case uint64: byteFloat, unit = ConvertByteUnit(float64(bytes)) case float64: byteFloat, unit = ConvertByteUnit(bytes) default: panic("bytes is not an int, int64 or uint64") } return fmt.Sprintf("%.1f", byteFloat) + unit } fmap["abstime"] = func(timeInt interface{}) interface{} { time, ok := timeInt.(time.Time) if !ok { panic("timeInt is not a time.Time") } return time.Format("2006-01-02 15:04:05") } fmap["reltime"] = func(timeInt interface{}) interface{} { time, ok := timeInt.(time.Time) if !ok { panic("timeInt is not a time.Time") } return RelativeTime(time) } fmap["scope"] = func(name interface{}) interface{} { return "" } fmap["dyntmpl"] = func(nameInt, pageInt, headerInt interface{}) interface{} { header := headerInt.(*Header) err := header.Theme.RunTmpl(nameInt.(string), pageInt, header.Writer) if err != nil { return err } return "" } fmap["ptmpl"] = func(nameInt, pageInt, headerInt interface{}) interface{} { header := headerInt.(*Header) err := header.Theme.RunTmpl(nameInt.(string), pageInt, header.Writer) if err != nil { return err } return "" } fmap["js"] = func() interface{} { return false } fmap["flush"] = func() interface{} { return nil } fmap["res"] = func(nameInt interface{}) interface{} { n := nameInt.(string) if n[0] == '/' && n[1] == '/' { } else { if f, ok := StaticFiles.GetShort(n); ok { n = f.OName } } return n } DefaultTemplateFuncMap = fmap } func loadTemplates(t *template.Template, themeName string) error { t.Funcs(DefaultTemplateFuncMap) tFiles, err := filepath.Glob("templates/*.html") if err != nil { return err } tFileMap := make(map[string]int) for index, path := range tFiles { path = strings.Replace(path, "\\", "/", -1) log.Print("templateFile: ", path) if skipCTmpl(path) { log.Print("skipping") continue } tFileMap[path] = index } overrideFiles, err := filepath.Glob("templates/overrides/*.html") if err != nil { return err } for _, path := range overrideFiles { path = strings.Replace(path, "\\", "/", -1) log.Print("overrideFile: ", path) if skipCTmpl(path) { log.Print("skipping") continue } index, ok := tFileMap["templates/"+strings.TrimPrefix(path, "templates/overrides/")] if !ok { log.Print("not ok: templates/" + strings.TrimPrefix(path, "templates/overrides/")) tFiles = append(tFiles, path) continue } tFiles[index] = path } if themeName != "" { overrideFiles, err := filepath.Glob("./themes/" + themeName + "/overrides/*.html") if err != nil { return err } for _, path := range overrideFiles { path = strings.Replace(path, "\\", "/", -1) log.Print("overrideFile: ", path) if skipCTmpl(path) { log.Print("skipping") continue } index, ok := tFileMap["templates/"+strings.TrimPrefix(path, "themes/"+themeName+"/overrides/")] if !ok { log.Print("not ok: templates/" + strings.TrimPrefix(path, "themes/"+themeName+"/overrides/")) tFiles = append(tFiles, path) continue } tFiles[index] = path } } // TODO: Minify these /*err = t.ParseFiles(tFiles...) if err != nil { return err }*/ for _, fname := range tFiles { b, err := ioutil.ReadFile(fname) if err != nil { return err } s := tmpl.Minify(string(b)) name := filepath.Base(fname) var tmpl *template.Template if name == t.Name() { tmpl = t } else { tmpl = t.New(name) } _, err = tmpl.Parse(s) if err != nil { return err } } _, err = t.ParseGlob("pages/*") return err } func InitTemplates() error { DebugLog("Initialising the template system") initDefaultTmplFuncMap() // The interpreted templates... DebugLog("Loading the template files...") return loadTemplates(DefaultTemplates, "") } ================================================ FILE: common/templates/context.go ================================================ package tmpl import ( "reflect" ) // For use in generated code type FragLite struct { Body string } type Fragment struct { Body string TemplateName string Index int Seen bool } type OutBufferFrame struct { Body string Type string TemplateName string Extra interface{} Extra2 interface{} } type CContext struct { RootHolder string VarHolder string HoldReflect reflect.Value RootTemplateName string TemplateName string LoopDepth int OutBuf *[]OutBufferFrame } func (con *CContext) Push(nType string, body string) (index int) { *con.OutBuf = append(*con.OutBuf, OutBufferFrame{body, nType, con.TemplateName, nil, nil}) return con.LastBufIndex() } func (con *CContext) PushText(body string, fragIndex int, fragOutIndex int) (index int) { *con.OutBuf = append(*con.OutBuf, OutBufferFrame{body, "text", con.TemplateName, fragIndex, fragOutIndex}) return con.LastBufIndex() } func (con *CContext) PushPhrase(langIndex int) (index int) { *con.OutBuf = append(*con.OutBuf, OutBufferFrame{"", "lang", con.TemplateName, langIndex, nil}) return con.LastBufIndex() } func (con *CContext) PushPhrasef(langIndex int, args string) (index int) { *con.OutBuf = append(*con.OutBuf, OutBufferFrame{args, "langf", con.TemplateName, langIndex, nil}) return con.LastBufIndex() } func (con *CContext) StartIf(body string) (index int) { *con.OutBuf = append(*con.OutBuf, OutBufferFrame{body, "startif", con.TemplateName, false, nil}) return con.LastBufIndex() } func (con *CContext) StartIfPtr(body string) (index int) { *con.OutBuf = append(*con.OutBuf, OutBufferFrame{body, "startif", con.TemplateName, true, nil}) return con.LastBufIndex() } func (con *CContext) EndIf(startIndex int, body string) (index int) { *con.OutBuf = append(*con.OutBuf, OutBufferFrame{body, "endif", con.TemplateName, startIndex, nil}) return con.LastBufIndex() } func (con *CContext) StartLoop(body string) (index int) { con.LoopDepth++ return con.Push("startloop", body) } func (con *CContext) EndLoop(body string) (index int) { return con.Push("endloop", body) } func (con *CContext) StartTemplate(body string) (index int) { return con.addFrame(body, "starttemplate", nil, nil) } func (con *CContext) EndTemplate(body string) (index int) { return con.Push("endtemplate", body) } func (con *CContext) AttachVars(vars string, index int) { outBuf := *con.OutBuf n := outBuf[index] if n.Type != "starttemplate" && n.Type != "startloop" && n.Type != "startif" { panic("not a starttemplate, startloop or startif node") } n.Body += vars outBuf[index] = n } func (con *CContext) addFrame(body, ftype string, extra1 interface{}, extra2 interface{}) (index int) { *con.OutBuf = append(*con.OutBuf, OutBufferFrame{body, ftype, con.TemplateName, extra1, extra2}) return con.LastBufIndex() } func (con *CContext) LastBufIndex() int { return len(*con.OutBuf) - 1 } func (con *CContext) DiscardAndAfter(index int) { outBuf := *con.OutBuf if len(outBuf) <= index { return } if index == 0 { outBuf = nil } else { outBuf = outBuf[:index] } *con.OutBuf = outBuf } ================================================ FILE: common/templates/minifiers.go ================================================ package tmpl import ( "strconv" "strings" ) // TODO: Write unit tests for this func Minify(data string) string { data = strings.Replace(data, "\t", "", -1) data = strings.Replace(data, "\v", "", -1) data = strings.Replace(data, "\n", "", -1) data = strings.Replace(data, "\r", "", -1) data = strings.Replace(data, " ", " ", -1) return data } // TODO: Strip comments // TODO: Handle CSS nested in ================================================ FILE: templates/guilds_guild_list.html ================================================ {{template "header.html" . }}
{{range .GuildList}}
{{.Name}}
{{.Desc}}
{{.MemberCount}} members
{{.LastUpdateTime}}
{{else}}
There aren't any visible guilds.
{{end}}
{{template "footer.html" . }} ================================================ FILE: templates/guilds_member_list.html ================================================ {{template "header.html" . }} {{/** TODO: Move this into a per-theme CSS file **/}} {{template "guilds_css.html" . }} {{/** TODO: Add next / prev bits **/}} {{/** TODO: Port the page template functions to the template interpreter **/}} {{if gt .Page 1}}{{end}} {{if ne .LastPage .Page}} {{end}}
{{range .ItemList}}
{{.RankString}}
{{.JoinedAt}}
{{.User.Name}} {{/** Use this for badges instead of rank? Both? Guild Titles? **/}}
{{.PostCount}} posts
{{end}}
{{template "footer.html" . }} ================================================ FILE: templates/guilds_view_guild.html ================================================ {{template "header.html" . }} {{/** TODO: Move this into a CSS file **/}} {{template "socialgroups_css.html" . }} {{/** TODO: Port the page template functions to the template interpreter **/}} {{if gt .Page 1}}{{end}} {{if ne .LastPage .Page}} {{end}}
{{range .ItemList}}
{{.PostCount}} replies
{{.LastReplyAt}}
{{.Title}}
Starter: {{.Creator.Name}} {{if .IsClosed}} | 🔒︎{{end}}
{{.LastUser.Name}}
Last: {{.LastReplyAt}}
{{else}}
There aren't any topics in here yet.{{if .CurrentUser.Perms.CreateTopic}} Start one?{{end}}
{{end}}
{{template "footer.html" . }} ================================================ FILE: templates/header.html ================================================ {{.Title}} | {{.Header.Site.Name}} {{range .Header.Stylesheets}} {{end}} {{range .Header.PreScriptsAsync}} {{end}} {{if .CurrentUser.Loggedin}}{{end}} {{range .Header.ScriptsAsync}} {{end}} {{range .Header.Scripts}} {{end}} {{if .Header.MetaDesc}}{{end}} {{/** TODO: Have page / forum / topic level tags and descriptions below as-well **/}} {{if .OGDesc}} {{end}} {{if .GoogSiteVerify}}{{end}} {{/**{{if not .CurrentUser.IsSuperMod}}{{end}}**/}}{{flush}}
{{/****/}}
{{dock "leftOfNav" .Header }}
{{/****/}} {{/** TODO: Make this a separate template and load it via the theme docks, here for now so we can rapidly prototype the Nox theme **/}} {{if eq .Header.Theme.Name "nox"}}
Avatar
{{.CurrentUser.Name}} {{lang "alerts.no_alerts_short"}}
{{end}}
{{/****/}}
{{range .Header.NoticeList}} {{template "notice.html" . }}{{end}}
================================================ FILE: templates/ip_search.html ================================================ {{template "header.html" . }}

{{lang "ip_search_head"}}

{{if .IP}}
{{range .ItemList}} {{else}}
{{lang "ip_search_no_users"}}
{{end}}
{{end}}
{{template "footer.html" . }} ================================================ FILE: templates/level_list.html ================================================ {{template "header.html" . }}

{{.Title}}

{{range .Levels}}
{{level .Level}}
{{if eq .Status "inprogress"}}{{$.CurrentUser.Score}} / {{.Score}}{{else if eq .Status "complete"}}{{.Score}} / {{.Score}}{{else}}Next: {{.Score}}{{end}}
{{end}}
{{template "footer.html" . }} ================================================ FILE: templates/login.html ================================================ {{template "header.html" . }}

{{lang "login_head"}}

{{template "footer.html" . }} ================================================ FILE: templates/login_mfa_verify.html ================================================ {{template "header.html" . }}

{{lang "login_mfa_verify_head"}}

{{template "footer.html" . }} ================================================ FILE: templates/menu_alerts.html ================================================ ================================================ FILE: templates/menu_item.html ================================================ ================================================ FILE: templates/notice.html ================================================
{{.}}
================================================ FILE: templates/overrides/filler.txt ================================================ This file is here so that Git will include this folder in the repository. ================================================ FILE: templates/overview.html ================================================ {{template "header.html" . }}
{{template "footer.html" . }} ================================================ FILE: templates/paginator.html ================================================ {{if gt .LastPage 1}}
{{if gt .Page 1}} {{end}} {{range .PageList}} {{end}} {{if ne .LastPage .Page}} {{end}}
{{end}} ================================================ FILE: templates/paginator_mod.html ================================================ {{if gt .LastPage 1}}
{{if gt .Page 1}} {{end}} {{range .PageList}} {{end}} {{if ne .LastPage .Page}} {{end}}
{{end}} ================================================ FILE: templates/panel.html ================================================ {{template "header.html" . }}
{{template "panel_menu.html" . }} {{template "panel_before_head.html" . }} {{dyntmpl .TmplName .Inner .Header}}
{{template "footer.html" . }} ================================================ FILE: templates/panel_adminlogs.html ================================================

{{lang "panel_logs_admin_head"}}

{{range .Logs}}
{{.Action}} {{if $.CurrentUser.Perms.ViewIPs}}
{{.IP}}{{end}}
{{.DoneAt}}
{{else}} {{end}}
{{template "paginator.html" . }} ================================================ FILE: templates/panel_analytics_active_memory.html ================================================

{{lang "panel_stats_active_memory_head"}}

{{template "panel_analytics_time_range_month.html" . }}

{{lang "panel_stats_details_head"}}

{{range .ViewItems}}
{{.Time}} {{.Count}}{{.Unit}}
{{else}}
{{lang "panel_stats_memory_no_memory"}}
{{end}}
{{template "panel_analytics_script_memory.html" . }} ================================================ FILE: templates/panel_analytics_agent_views.html ================================================

{{.FriendlyAgent}}{{lang "panel_stats_views_head_suffix"}}

{{template "panel_analytics_time_range.html" . }}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_agents.html ================================================

{{lang "panel_stats_user_agents_head"}}

{{template "panel_analytics_time_range.html" . }}
{{range .ItemList}}
{{.FriendlyAgent}} {{.Count}}{{lang "panel_stats_views_suffix"}}
{{else}}
{{lang "panel_stats_user_agents_no_user_agents"}}
{{end}}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_forum_views.html ================================================

{{.FriendlyAgent}}{{lang "panel_stats_views_head_suffix"}}

{{template "panel_analytics_time_range.html" . }}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_forums.html ================================================

{{lang "panel_stats_forums_head"}}

{{template "panel_analytics_time_range.html" . }}
{{range .ItemList}}
{{.FriendlyAgent}} {{.Count}}{{lang "panel_stats_views_suffix"}}
{{else}}
{{lang "panel_stats_forums_no_forums"}}
{{end}}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_lang_views.html ================================================

{{.FriendlyAgent}}{{lang "panel_stats_views_head_suffix"}}

{{template "panel_analytics_time_range.html" . }}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_langs.html ================================================

{{lang "panel_stats_languages_head"}}

{{template "panel_analytics_time_range.html" . }}
{{range .ItemList}}
{{.FriendlyAgent}} {{.Count}}{{lang "panel_stats_views_suffix"}}
{{else}}
{{lang "panel_stats_languages_no_languages"}}
{{end}}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_memory.html ================================================

{{lang "panel_stats_memory_head"}}

{{template "panel_analytics_time_range_month.html" . }}

{{lang "panel_stats_details_head"}}

{{range .ViewItems}}
{{.Time}} {{.Count}}{{.Unit}}
{{else}}
{{lang "panel_stats_memory_no_memory"}}
{{end}}
{{template "panel_analytics_script_memory.html" . }} ================================================ FILE: templates/panel_analytics_performance.html ================================================

{{lang "panel_stats_perf_head"}}

{{template "panel_analytics_time_range_month.html" . }}

{{lang "panel_stats_details_head"}}

{{range .ViewItems}}
{{.Time}} {{.Count}}{{.Unit}}
{{else}}
{{lang "panel_stats_perf_no_perf"}}
{{end}}
{{template "panel_analytics_script_perf.html" . }} ================================================ FILE: templates/panel_analytics_posts.html ================================================

{{lang "panel_stats_post_counts_head"}}

{{template "panel_analytics_time_range.html" . }}

{{lang "panel_stats_details_head"}}

{{range .ViewItems}}
{{.Time}} {{.Count}}{{lang "panel_stats_posts_suffix"}}
{{else}}
{{lang "panel_stats_post_counts_no_post_counts"}}
{{end}}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_referrer_views.html ================================================

{{.Agent}}{{lang "panel_stats_views_head_suffix"}}

{{template "panel_analytics_time_range.html" . }}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_referrers.html ================================================

{{lang "panel_stats_referrers_head"}}

{{template "panel_analytics_time_range.html" . }}
{{range .ItemList}}
{{.Agent}} {{.Count}}{{lang "panel_stats_views_suffix"}}
{{else}}
{{lang "panel_stats_referrers_no_referrers"}}
{{end}}
================================================ FILE: templates/panel_analytics_route_views.html ================================================

{{.Route}}{{lang "panel_stats_views_head_suffix"}}

{{template "panel_analytics_time_range.html" . }}
{{range .ViewItems}}
{{.Time}} {{.Count}}{{lang "panel_stats_views_suffix"}}
{{end}}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_routes.html ================================================

{{lang "panel_stats_routes_head"}}

{{template "panel_analytics_time_range.html" . }}
{{range .ItemList}}
{{.Route}} {{.Count}}{{lang "panel_stats_views_suffix"}}
{{else}}
{{lang "panel_stats_routes_no_routes"}}
{{end}}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_routes_perf.html ================================================

{{lang "panel_stats_routes_perf_head"}}

{{template "panel_analytics_time_range.html" . }}
{{range .ItemList}}
{{.Route}} {{.Count}}{{.Unit}}
{{else}}
{{lang "panel_stats_routes_no_routes"}}
{{end}}
{{template "panel_analytics_script_perf.html" . }} ================================================ FILE: templates/panel_analytics_script.html ================================================ ================================================ FILE: templates/panel_analytics_script_memory.html ================================================ ================================================ FILE: templates/panel_analytics_script_perf.html ================================================ ================================================ FILE: templates/panel_analytics_system_views.html ================================================

{{.FriendlyAgent}}{{lang "panel_stats_views_head_suffix"}}

{{template "panel_analytics_time_range.html" . }}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_systems.html ================================================

{{lang "panel_stats_operating_systems_head"}}

{{template "panel_analytics_time_range.html" . }}
{{range .ItemList}}
{{.FriendlyAgent}} {{.Count}}{{lang "panel_stats_views_suffix"}}
{{else}}
{{lang "panel_stats_operating_systems_no_operating_systems"}}
{{end}}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_time_range.html ================================================ ================================================ FILE: templates/panel_analytics_time_range_month.html ================================================ ================================================ FILE: templates/panel_analytics_topics.html ================================================

{{lang "panel_stats_topic_counts_head"}}

{{template "panel_analytics_time_range.html" . }}

{{lang "panel_stats_details_head"}}

{{range .ViewItems}}
{{.Time}} {{.Count}}{{lang "panel_stats_topics_suffix"}}
{{end}}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_analytics_views.html ================================================

{{lang "panel_stats_requests_head"}}

{{template "panel_analytics_time_range.html" . }}

{{lang "panel_stats_details_head"}}

{{range .ViewItems}}
{{.Time}} {{.Count}}{{lang "panel_stats_views_suffix"}}
{{end}}
{{template "panel_analytics_script.html" . }} ================================================ FILE: templates/panel_are_you_sure.html ================================================ {{template "header.html" . }}
{{template "panel_menu.html" . }}
{{template "panel_before_head.html" . }}

{{lang "areyousure_head"}}

{{.Something.Message}}

{{lang "areyousure_continue"}}
{{template "footer.html" . }} ================================================ FILE: templates/panel_backups.html ================================================

{{lang "panel_backups_head"}}

{{range .Backups}} {{else}}
{{lang "panel_backups_no_backups"}}
{{end}}
================================================ FILE: templates/panel_before_head.html ================================================ ================================================ FILE: templates/panel_dashboard.html ================================================

{{lang "panel_dashboard_head"}}

{{flush}}
{{range .Grid1}}
{{if .Href}}{{.Body}}{{else}}{{.Body}}{{end}}
{{end}}
{{flush}}
{{range .Grid2}}
{{if .Href}}{{.Body}}{{else}}{{.Body}}{{end}}
{{end}}
================================================ FILE: templates/panel_debug.html ================================================

{{lang "panel_debug_head"}}

{{flush}}
{{template "panel_debug_stat_head.html" "panel_debug_uptime_label"}} {{template "panel_debug_stat_head.html" "panel_debug_go_version_label"}} {{template "panel_debug_stat_head.html" "panel_debug_database_version_label"}} {{template "panel_debug_stat.html" .Uptime}} {{template "panel_debug_stat.html" .GoVersion}} {{template "panel_debug_stat.html" .DBVersion}} {{template "panel_debug_stat_head.html" "panel_debug_open_database_connections_label"}} {{template "panel_debug_stat_head.html" "panel_debug_adapter_label"}} {{/** TODO: Use this for active database connections when Go 1.11 lands **/}} {{template "panel_debug_stat_head_q.html"}} {{template "panel_debug_stat.html" .DBConns}} {{template "panel_debug_stat.html" .DBAdapter}} {{template "panel_debug_stat_q.html"}} {{template "panel_debug_stat_head.html" "panel_debug_goroutine_count_label"}} {{template "panel_debug_stat_head.html" "panel_debug_cpu_count_label"}} {{template "panel_debug_stat_head.html" "panel_debug_http_conns_label"}} {{template "panel_debug_stat.html" .Goroutines}} {{template "panel_debug_stat.html" .CPUs}} {{template "panel_debug_stat.html" .HttpConns}}
{{template "panel_debug_subhead.html" "panel_debug_tasks"}}
{{template "panel_debug_stat_head.html" "panel_debug_tasks_half_second"}} {{template "panel_debug_stat_head.html" "panel_debug_tasks_second"}} {{template "panel_debug_stat_head.html" "panel_debug_tasks_fifteen_minute"}} {{template "panel_debug_stat.html" .Tasks.HalfSecond}} {{template "panel_debug_stat.html" .Tasks.Second}} {{template "panel_debug_stat.html" .Tasks.FifteenMinute}} {{template "panel_debug_stat_head.html" "panel_debug_tasks_hour"}} {{template "panel_debug_stat_head.html" "panel_debug_tasks_shutdown"}} {{template "panel_debug_stat_head_q.html"}} {{template "panel_debug_stat.html" .Tasks.Hour}} {{template "panel_debug_stat.html" .Tasks.Shutdown}} {{template "panel_debug_stat_q.html"}}
{{template "panel_debug_subhead.html" "panel_debug_memory_stats"}}
{{template "panel_debug_stat_head.html" "panel_debug_memory_stats_sys"}} {{template "panel_debug_stat_head.html" "panel_debug_memory_stats_heapsys"}} {{template "panel_debug_stat_head.html" "panel_debug_memory_stats_heapalloc"}}
{{.MemStats.Sys}} ({{bunit .MemStats.Sys}})
{{.MemStats.HeapSys}} ({{bunit .MemStats.HeapSys}})
{{.MemStats.HeapAlloc}} ({{bunit .MemStats.HeapAlloc}})
{{template "panel_debug_stat_head.html" "panel_debug_memory_stats_heapidle"}} {{template "panel_debug_stat_head.html" "panel_debug_memory_stats_heapobjects"}} {{template "panel_debug_stat_head.html" "panel_debug_memory_stats_stackinuse"}}
{{.MemStats.HeapIdle}} ({{bunit .MemStats.HeapIdle}})
{{.MemStats.HeapObjects}}
{{.MemStats.StackInuse}} ({{bunit .MemStats.StackInuse}})
{{template "panel_debug_stat_head.html" "panel_debug_memory_stats_mspaninuse"}} {{template "panel_debug_stat_head.html" "panel_debug_memory_stats_mcacheinuse"}} {{template "panel_debug_stat_head.html" "panel_debug_memory_stats_mspansys"}}
{{.MemStats.MSpanInuse}} ({{bunit .MemStats.MSpanInuse}})
{{.MemStats.MCacheInuse}} ({{bunit .MemStats.MCacheInuse}})
{{.MemStats.MSpanSys}} ({{bunit .MemStats.MSpanSys}})
{{template "panel_debug_stat_head.html" "panel_debug_memory_stats_mcachesys"}} {{template "panel_debug_stat_head.html" "panel_debug_memory_stats_gcsys"}} {{template "panel_debug_stat_head.html" "panel_debug_memory_stats_othersys"}}
{{.MemStats.MCacheSys}} ({{bunit .MemStats.MCacheSys}})
{{.MemStats.GCSys}} ({{bunit .MemStats.GCSys}})
{{.MemStats.OtherSys}} ({{bunit .MemStats.OtherSys}})
{{template "panel_debug_subhead.html" "panel_debug_caches"}}
{{template "panel_debug_stat_head.html" "panel_debug_caches_topic"}} {{template "panel_debug_stat_head.html" "panel_debug_caches_user"}} {{template "panel_debug_stat_head.html" "panel_debug_caches_reply"}}
{{.Cache.Topics}} / {{.Cache.TCap}}
{{.Cache.Users}} / {{.Cache.UCap}}
{{.Cache.Replies}} / {{.Cache.RCap}}
{{template "panel_debug_stat_head.html" "panel_debug_caches_topic_list"}} {{template "panel_debug_stat_head_q.html"}} {{template "panel_debug_stat_head_q.html"}}
{{if .Cache.TopicListThaw}}Thawed{{else}}Sleeping{{end}}
{{template "panel_debug_stat_q.html"}} {{template "panel_debug_stat_q.html"}}
{{template "panel_debug_subhead.html" "panel_debug_database"}}
{{template "panel_debug_stat_head.html" "panel_debug_database_topics"}} {{template "panel_debug_stat_head.html" "panel_debug_database_users"}} {{template "panel_debug_stat_head.html" "panel_debug_database_replies"}} {{template "panel_debug_stat.html" .Database.Topics}} {{template "panel_debug_stat.html" .Database.Users}} {{template "panel_debug_stat.html" .Database.Replies}} {{template "panel_debug_stat_head.html" "panel_debug_database_profile_replies"}} {{template "panel_debug_stat_head.html" "panel_debug_database_activity_stream"}} {{template "panel_debug_stat_head.html" "panel_debug_database_likes"}} {{template "panel_debug_stat.html" .Database.ProfileReplies}} {{template "panel_debug_stat.html" .Database.ActivityStream}} {{template "panel_debug_stat.html" .Database.Likes}} {{template "panel_debug_stat_head.html" "panel_debug_database_attachments"}} {{template "panel_debug_stat_head.html" "panel_debug_database_polls"}} {{template "panel_debug_stat_head_q.html"}} {{template "panel_debug_stat.html" .Database.Attachments}} {{template "panel_debug_stat.html" .Database.Polls}} {{template "panel_debug_stat_q.html"}} {{template "panel_debug_stat_head.html" "panel_debug_database_login_logs"}} {{template "panel_debug_stat_head.html" "panel_debug_database_reg_logs"}} {{template "panel_debug_stat_head.html" "panel_debug_database_mod_logs"}} {{template "panel_debug_stat.html" .Database.LoginLogs}} {{template "panel_debug_stat.html" .Database.RegLogs}} {{template "panel_debug_stat.html" .Database.ModLogs}} {{template "panel_debug_stat_head.html" "panel_debug_database_admin_logs"}} {{template "panel_debug_stat_head_q.html"}} {{template "panel_debug_stat_head_q.html"}} {{template "panel_debug_stat.html" .Database.AdminLogs}} {{template "panel_debug_stat_q.html"}} {{template "panel_debug_stat_q.html"}} {{template "panel_debug_stat_head.html" "panel_debug_database_views"}} {{template "panel_debug_stat_head.html" "panel_debug_database_views_agents"}} {{template "panel_debug_stat_head.html" "panel_debug_database_views_forums"}} {{template "panel_debug_stat.html" .Database.Views}} {{template "panel_debug_stat.html" .Database.ViewsAgents}} {{template "panel_debug_stat.html" .Database.ViewsForums}} {{template "panel_debug_stat_head.html" "panel_debug_database_views_langs"}} {{template "panel_debug_stat_head.html" "panel_debug_database_views_referrers"}} {{template "panel_debug_stat_head.html" "panel_debug_database_views_systems"}} {{template "panel_debug_stat.html" .Database.ViewsLangs}} {{template "panel_debug_stat.html" .Database.ViewsReferrers}} {{template "panel_debug_stat.html" .Database.ViewsSystems}} {{template "panel_debug_stat_head.html" "panel_debug_database_post_analytics"}} {{template "panel_debug_stat_head.html" "panel_debug_database_topic_analytics"}} {{template "panel_debug_stat_head_q.html"}} {{template "panel_debug_stat.html" .Database.PostChunks}} {{template "panel_debug_stat.html" .Database.TopicChunks}} {{template "panel_debug_stat_q.html"}}
{{template "panel_debug_subhead.html" "panel_debug_disk"}}
{{template "panel_debug_stat_head.html" "panel_debug_disk_static_files"}} {{template "panel_debug_stat_head.html" "panel_debug_disk_attachments"}} {{template "panel_debug_stat_head.html" "panel_debug_disk_avatars"}}
{{bunit .Disk.Static}}
{{bunit .Disk.Attachments}}
{{bunit .Disk.Avatars}}
{{template "panel_debug_stat_head.html" "panel_debug_disk_log_files"}} {{template "panel_debug_stat_head.html" "panel_debug_disk_backups"}} {{template "panel_debug_stat_head.html" "panel_debug_disk_git"}}
{{bunit .Disk.Logs}}
{{bunit .Disk.Backups}}
{{bunit .Disk.Git}}
{{flush}} ================================================ FILE: templates/panel_debug_stat.html ================================================
{{.}}
================================================ FILE: templates/panel_debug_stat_head.html ================================================
{{lang .}}
================================================ FILE: templates/panel_debug_stat_head_q.html ================================================
???
================================================ FILE: templates/panel_debug_stat_q.html ================================================
?
================================================ FILE: templates/panel_debug_subhead.html ================================================

{{lang .}}

{{flush}} ================================================ FILE: templates/panel_forum_edit.html ================================================

{{.Name}}{{lang "panel.forum_head_suffix"}}

{{lang "panel.forum_permissions_head"}}

{{range .Groups}} {{end}}
{{if .Actions}}

{{lang "panel.forum_actions_head"}}

{{end}}

{{lang "panel.forum_actions_create_head"}}

================================================ FILE: templates/panel_forum_edit_perms.html ================================================

{{.Name}}{{lang "panel.forum_head_suffix"}}

{{range .Perms}}
{{.LangStr}}
{{end}}
================================================ FILE: templates/panel_forums.html ================================================

{{lang "panel.forums_head"}}

{{lang "panel_hints_reorder"}}

{{range .ItemList}}
{{/** TODO: Make sure the forum_active_name class is set and unset when the activity status of this forum is changed **/}} {{.Name}}
{{.Desc}}
{{if gt .ID 1}}{{end}}
{{end}}

{{lang "panel.forums_create_head"}}

================================================ FILE: templates/panel_group_edit.html ================================================ {{template "header.html" . }}
{{template "panel_group_menu.html" . }}
{{template "panel_before_head.html" . }}

{{.Name}}{{lang "panel_group_head_suffix"}} - #{{.ID}}

{{if .CurrentUser.Perms.EditGroup}}
{{end}}
{{template "footer.html" . }} ================================================ FILE: templates/panel_group_edit_perms.html ================================================ {{template "header.html" . }}
{{template "panel_group_menu.html" . }}
{{template "panel_before_head.html" . }}

{{.Name}}{{lang "panel_group_head_suffix"}} - #{{.ID}}

{{if .CurrentUser.Perms.EditGroupLocalPerms}}
{{range .LocalPerms}}
{{.LangStr}}
{{end}}
{{end}} {{if .CurrentUser.Perms.EditGroupGlobalPerms}}

{{lang "panel_group_extended_permissions"}}

{{range .GlobalPerms}}
{{.LangStr}}
{{end}}
{{end}} {{if .CurrentUser.Perms.EditGroupGlobalPerms}}

{{lang "panel_group_mod_permissions"}}

{{range .ModPerms}}
{{.LangStr}}
{{end}}
{{end}}
{{template "footer.html" . }} ================================================ FILE: templates/panel_group_edit_promotions.html ================================================ {{template "header.html" . }}
{{template "panel_group_menu.html" . }}
{{template "panel_before_head.html" . }}

{{.Name}}{{lang "panel_group_head_suffix"}}

{{range .Promotions}}
{{.FromGroup.Name}} -> {{.ToGroup.Name}}{{if .TwoWay}} (two way){{end}} {{if .Level}} - {{lang "panel_group_promotions_row_level_prefix"}}{{.Level}}{{end}} {{if .Posts}} - {{lang "panel_group_promotions_row_posts_prefix"}}{{.Posts}}{{end}} {{if .RegisteredFor}} - {{langf "panel_group_promotions_row_registered_minutes" .RegisteredFor}}{{end}}
{{end}}
{{if .CurrentUser.Perms.EditGroup}}

{{lang "panel_group_promotions_create_head"}}

{{lang "panel_group_promotion_reg_months_suffix"}}
{{lang "panel_group_promotion_reg_days_suffix"}}
{{lang "panel_group_promotion_reg_hours_suffix"}}
{{end}}
{{template "footer.html" . }} ================================================ FILE: templates/panel_group_menu.html ================================================ ================================================ FILE: templates/panel_groups.html ================================================

{{lang "panel_groups_head"}}

{{range .ItemList}}
{{.Name}} {{if .RankClass}} {{else}}{{.Rank}}{{end}} {{if .CanEdit}}{{end}}
{{end}}
{{template "paginator.html" . }} {{if .CurrentUser.Perms.EditGroup}}

{{lang "panel_groups_create_head"}}

{{end}} ================================================ FILE: templates/panel_inner_menu.html ================================================
{{if .CurrentUser.Perms.ManagePlugins}}{{end}} {{if .CurrentUser.IsSuperAdmin}}{{end}} {{if .CurrentUser.IsAdmin}} {{if .DebugAdmin}}{{end}} {{end}}
================================================ FILE: templates/panel_menu.html ================================================ ================================================ FILE: templates/panel_modlogs.html ================================================

{{lang "panel_logs_mod_head"}}

{{range .Logs}}
{{.Action}} {{if $.CurrentUser.Perms.ViewIPs}}
{{.IP}}{{end}}
{{.DoneAt}}
{{else}}{{end}}
{{template "paginator.html" . }} ================================================ FILE: templates/panel_pages.html ================================================

{{lang "panel_pages_head"}}

{{range .ItemList}} {{else}} {{end}}

{{lang "panel_pages_create_head"}}

================================================ FILE: templates/panel_pages_edit.html ================================================

{{lang "panel_pages_edit_head"}}

================================================ FILE: templates/panel_plugins.html ================================================

{{lang "panel_plugins_head"}}

{{range .ItemList}}
{{.Name}}
{{lang "panel_plugins_author_prefix"}}{{.Author}}
{{if .Settings}}{{lang "panel_plugins_settings"}}{{end}} {{if .Active}}{{lang "panel_plugins_deactivate"}} {{else if .Installable}} {{/** TODO: Write a custom template interpreter to fix this nonsense **/}} {{if .Installed}}{{lang "panel_plugins_activate"}}{{else}}{{lang "panel_plugins_install"}}{{end}} {{else}}{{lang "panel_plugins_activate"}}{{end}}
{{end}}
================================================ FILE: templates/panel_reglogs.html ================================================

{{lang "panel_logs_reg_head"}}

{{range .Logs}}
{{if not .Success}}{{lang "panel_logs_reg_attempt"}}{{end}}{{.Username}}{{if .Email}} ({{lang "panel_logs_reg_email"}}{{.Email}}){{end}}{{if .ParsedReason}} ({{lang "panel_logs_reg_reason"}}{{.ParsedReason}}){{end}}
{{if $.CurrentUser.Perms.ViewIPs}}{{.IP}}{{end}} {{.DoneAt}}
{{else}}{{end}}
{{template "paginator.html" . }} ================================================ FILE: templates/panel_setting.html ================================================

{{.Setting.FriendlyName}}

{{if eq .Setting.Type "list"}}
{{else if eq .Setting.Type "bool"}}
{{else if eq .Setting.Type "textarea"}}
{{else}}{{end}}
================================================ FILE: templates/panel_settings.html ================================================

{{lang "panel_settings_head"}}

{{range .Something}} {{end}}
================================================ FILE: templates/panel_themes.html ================================================

{{lang "panel_themes_primary_themes"}}

{{range .PrimaryThemes}}
{{.FriendlyName}}
{{lang "panel_themes_author_prefix"}}{{.Creator}}
{{if .MobileFriendly}}📱{{end}} {{if .Tag}}{{.Tag}}{{end}} {{if .Active}}{{lang "panel_themes_default"}}{{else}}{{lang "panel_themes_make_default"}}{{end}}
{{end}}
{{if .VariantThemes}}

{{lang "panel_themes_variant_themes"}}

{{range .VariantThemes}}
{{.FriendlyName}}
{{lang "panel_themes_author_prefix"}}{{.Creator}}
{{if .MobileFriendly}}📱{{end}} {{if .Tag}}{{.Tag}}{{end}} {{if .Active}}{{lang "panel_themes_default"}}{{else}}{{lang "panel_themes_make_default"}}{{end}}
{{end}}
{{end}} ================================================ FILE: templates/panel_themes_menus.html ================================================

{{lang "panel_themes_menus_head"}}

================================================ FILE: templates/panel_themes_menus_item_edit.html ================================================ {{/** TODO: Set the order based on the order here **/}} {{/** TODO: Write the backend code and JS code for saving this menu **/}}

{{lang "panel_themes_menus_edit_head"}}

{{/** TODO: Let an admin move a menu item from one menu to another? **/}}
================================================ FILE: templates/panel_themes_menus_items.html ================================================

{{lang "panel_themes_menus_items_head"}}

{{lang "panel_hints_reorder"}}

{{range .ItemList}} {{end}}

{{lang "panel_themes_menus_create_head"}}

{{/** TODO: Let an admin move a menu item from one menu to another? **/}}
================================================ FILE: templates/panel_themes_widgets.html ================================================ {{/** type Widget struct { Enabled bool Location string // Coming Soon: overview, topics, topic / topic_view, forums, forum, global Position int Body string Side string Type string Literal bool } **/}}

{{lang "panel_themes_widgets_head"}}

{{range $name, $dock := .Docks}}

{{$name}}

{{range $widget := $dock}}
{{template "panel_themes_widgets_widget.html" $widget }}
{{end}}
{{end}}
{{template "panel_themes_widgets_widget.html" .BlankWidget }}
================================================ FILE: templates/panel_themes_widgets_widget.html ================================================
================================================ FILE: templates/panel_user_edit.html ================================================

{{lang "panel_user_head"}}

{{if .User.RawAvatar}}{{end}}
{{if .User.RawAvatar}}{{end}}
{{if .CurrentUser.Perms.EditUserPassword}}{{end}} {{if .CurrentUser.Perms.EditUserEmail}}{{end}} {{if .CurrentUser.Perms.EditUserGroup}}
{{end}}
================================================ FILE: templates/panel_users.html ================================================

{{if .Search.Any}}{{lang "panel_users_search_title"}}{{else}}{{lang "panel_users_head"}}{{end}}

{{range .ItemList}}
{{.Name}} {{lang "panel_users_profile"}} {{if (.Tag) and (.IsSuperMod)}}{{.Tag}}{{end}} {{if .IsBanned}}{{lang "panel_users_unban"}}{{else if not .IsSuperMod}}{{lang "panel_users_ban"}}{{end}} {{if not .Active}}{{lang "panel_users_activate"}}{{end}}
{{end}}
{{template "paginator_mod.html" . }}

{{lang "panel_users_search_head"}}

{{if .CurrentUser.Perms.EditUserEmail}}{{end}} {{if .CurrentUser.Perms.EditUserGroup}}
{{end}}
================================================ FILE: templates/panel_word_filters.html ================================================

{{lang "panel_word_filters_head"}}

{{lang "panel_word_filters_create_head"}}

================================================ FILE: templates/password_reset.html ================================================ {{template "header.html" . }}

{{lang "password_reset_head"}}

{{template "footer.html" . }} ================================================ FILE: templates/password_reset_token.html ================================================ {{template "header.html" . }}

{{lang "password_reset_token_head"}}

{{template "footer.html" . }} ================================================ FILE: templates/profile.html ================================================ {{template "header.html" . }}
{{.ProfileOwner.Name}}{{if .ProfileOwner.Tag}}{{.ProfileOwner.Tag}}{{end}}
{{.CurrentScore}} / {{.NextScore}}
{{if not .CurrentUser.Loggedin}}{{else}} {{if .CanMessage}}{{end}} {{if (.CurrentUser.IsSuperMod) and not (.ProfileOwner.IsSuperMod)}}
{{if .ProfileOwner.IsBanned}}{{lang "profile.unban"}} {{else}}{{lang "profile.ban"}}{{end}}
{{end}}
{{end}}
{{if .CurrentUser.Loggedin}} {{if .CurrentUser.Perms.BanUsers}} {{end}} {{end}} {{if .ShowComments}}
{{template "profile_comments_row.html" . }}
{{end}} {{if .CurrentUser.Loggedin}} {{if .CanComment}}
{{end}} {{else}}
{{lang "profile.comments_form_guest"}}
{{end}}
{{template "footer.html" . }} ================================================ FILE: templates/profile_comments_row.html ================================================ {{/** TODO: Temporary hack until we find a more granular way of doing this. Perhaps, a custom include function? **/}} {{if .Header.Theme.BgAvatars}} {{range .ItemList}}
{{.ContentHtml}} {{.CreatedByName}}   {{if $.CurrentUser.IsMod}} {{end}} {{if .Tag}}{{.Tag}}{{end}}
{{end}} {{else}} {{template "profile_comments_row_alt.html" . }} {{end}} ================================================ FILE: templates/profile_comments_row_alt.html ================================================ {{range .ItemList}}
{{.CreatedByName}} {{if .Tag}}{{.Tag}}{{end}}
{{if $.CurrentUser.IsMod}} {{end}}
{{.ContentHtml}}
{{end}} ================================================ FILE: templates/register.html ================================================ {{template "header.html" . }}

{{lang "register_head"}}

{{/** This is not a TOS, that text is there to fool the spambots **/}}
{{range .Verify}}{{template "register_verify.html" .}}{{end}}
{{if eq .Token ""}}{{else}}{{end}}
{{template "footer.html" . }} ================================================ FILE: templates/register_verify.html ================================================ {{if .NoScript}}{{end}} ================================================ FILE: templates/topic.html ================================================ {{template "header.html" . }} {{template "topic_inner.html" . }} {{template "footer.html" . }} ================================================ FILE: templates/topic_alt.html ================================================ {{template "header.html" . }} {{template "topic_alt_inner.html" . }} {{template "footer.html" . }} ================================================ FILE: templates/topic_alt_inner.html ================================================
{{if gt .Page 1}}{{end}} {{if ne .LastPage .Page}}{{end}} {{if not .CurrentUser.Loggedin}}{{end}}

{{.Topic.Title}}

- {{.Forum.Name}} {{/** TODO: Does this need to be guarded by a permission? It's only visible in edit mode anyway, which can't be triggered, if they don't have the permission **/}} {{if .CurrentUser.Loggedin}} {{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}} {{if .CurrentUser.Perms.EditTopic}}
{{end}} {{end}} {{end}} {{.Topic.ViewCount}} {{/** TODO: Inline this CSS **/}} {{if .Topic.IsClosed}}🔒︎{{end}}
{{if .Poll}}{{template "topic_alt_poll.html" . }}{{end}}
{{template "topic_alt_userinfo.html" .Topic }}
{{.Topic.ContentHTML}}
{{if .CurrentUser.Loggedin}} {{if .CurrentUser.Perms.EditTopic}}
{{range .Topic.Attachments}}
{{if .Image}}{{end}} {{.Path}}
{{end}}
{{if .CurrentUser.Perms.UploadFiles}} {{end}}
{{end}}{{end}}
{{if .CurrentUser.Loggedin}} {{if .CurrentUser.Perms.LikeItem}}{{if ne .CurrentUser.ID .Topic.CreatedBy}} {{if .Topic.Liked}}{{else}}{{end}} {{end}}{{end}} {{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}} {{if .CurrentUser.Perms.EditTopic}}{{end}} {{end}} {{if .Topic.Deletable}}{{end}} {{if .CurrentUser.Perms.CloseTopic}} {{if .Topic.IsClosed}}{{else}}{{end}}{{end}} {{if .CurrentUser.Perms.PinTopic}} {{if .Topic.Sticky}}{{else}}{{end}}{{end}} {{if .CurrentUser.Perms.ViewIPs}}{{end}} {{end}}
{{reltime .Topic.CreatedAt}} {{if .CurrentUser.Perms.ViewIPs}}{{end}}
{{template "topic_alt_posts.html" . }}
{{template "paginator.html" . }} {{if .CurrentUser.Loggedin}} {{if .CurrentUser.Perms.CreateReply}} {{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}} {{template "topic_alt_quick_reply.html" . }} {{end}} {{end}} {{end}}
================================================ FILE: templates/topic_alt_mini.html ================================================
{{range .Header.NoticeList}}{{template "notice.html" . }}{{end}}
{{template "topic_alt_inner.html" . }}
================================================ FILE: templates/topic_alt_poll.html ================================================
{{/**{{template "topic_alt_userinfo.html" .Topic }}**/}}
{{range .Poll.QuickOptions}}
{{.Value}}
{{end}}
{{lang "topic.poll_no_results"}}
================================================ FILE: templates/topic_alt_posts.html ================================================ {{range .ItemList}}
{{if js}}js{{/**{{ptmpl "topic_alt_userinfo" .}}**/}}{{else}}{{template "topic_alt_userinfo.html" . }}{{end}}
{{if .ActionType}} {{.ActionType}} {{else}}
{{.ContentHtml}}
{{if $.CurrentUser.Loggedin}}
{{.Content}}
{{if $.CurrentUser.Perms.EditReply}}
{{range .Attachments}}
{{if .Image}}{{end}} {{.Path}}
{{end}}
{{if $.CurrentUser.Perms.UploadFiles}} {{end}}
{{end}}{{end}}
{{if $.CurrentUser.Loggedin}} {{if $.CurrentUser.Perms.LikeItem}}{{if ne $.CurrentUser.ID .CreatedBy}} {{if .Liked}}{{else}} {{end}} {{end}}{{end}} {{if not $.Topic.IsClosed or $.CurrentUser.Perms.CloseTopic}} {{if $.CurrentUser.Perms.EditReply}}{{end}} {{end}} {{if .Deletable}}{{end}} {{if $.CurrentUser.Perms.ViewIPs}}{{end}} {{end}}
{{reltime .CreatedAt}} {{if $.CurrentUser.Loggedin}}{{if $.CurrentUser.Perms.ViewIPs}}{{end}}{{end}}
{{end}}
{{end}} ================================================ FILE: templates/topic_alt_quick_reply.html ================================================
Avatar
{{if .CurrentUser.Tag}}
{{if .CurrentUser.Perms.UploadFiles}}
{{end}}
================================================ FILE: templates/topic_alt_userinfo.html ================================================
Avatar
{{if .Tag}}
================================================ FILE: templates/topic_c_attach_item.html ================================================ {{if .ImgSrc}}{{end}} {{.Path}} ================================================ FILE: templates/topic_c_edit_post.html ================================================
================================================ FILE: templates/topic_c_poll_input.html ================================================
================================================ FILE: templates/topic_inner.html ================================================
{{if gt .Page 1}} {{end}} {{if ne .LastPage .Page}} {{end}} {{if not .CurrentUser.Loggedin}}{{end}}

{{.Topic.Title}}

{{if .Topic.IsClosed}}🔒︎{{end}} {{/** TODO: Does this need to be guarded by a permission? It's only visible in edit mode anyway, which can't be triggered, if they don't have the permission **/}} {{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}} {{if .CurrentUser.Perms.EditTopic}}
{{end}} {{end}}
{{if .Poll}}{{template "topic_poll.html" . }}{{end}}
{{.Topic.ContentHTML}}
{{if .CurrentUser.Loggedin}}{{end}}    {{if .CurrentUser.Loggedin}} {{if .CurrentUser.Perms.LikeItem}}{{if ne .CurrentUser.ID .Topic.CreatedBy}} {{if .Topic.Liked}} {{else}} {{end}} {{end}}{{end}} {{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}} {{if .CurrentUser.Perms.EditTopic}}{{end}} {{end}} {{if .Topic.Deletable}}{{end}} {{if .CurrentUser.Perms.CloseTopic}}{{if .Topic.IsClosed}}{{else}}{{end}}{{end}} {{if .CurrentUser.Perms.PinTopic}}{{if .Topic.Sticky}}{{else}}{{end}}{{end}} {{if .CurrentUser.Perms.ViewIPs}}{{end}} {{end}} {{if .Topic.Tag}}{{.Topic.Tag}}{{else}}{{level .Topic.Level}}{{end}}
{{template "topic_posts.html" . }} {{if .CurrentUser.Perms.CreateReply}} {{if not .Topic.IsClosed or .CurrentUser.Perms.CloseTopic}}
{{if .CurrentUser.Perms.UploadFiles}}
{{end}}
{{end}} {{end}}
================================================ FILE: templates/topic_mini.html ================================================
{{range .Header.NoticeList}}{{template "notice.html" . }}{{end}}
{{template "topic_inner.html" . }}
================================================ FILE: templates/topic_poll.html ================================================
{{range .Poll.QuickOptions}}
{{.Value}}
{{end}}
{{lang "topic.poll_no_results"}}
================================================ FILE: templates/topic_posts.html ================================================
{{range .ItemList}} {{if .ActionType}}
{{.ActionIcon}} {{.ActionType}}
{{else}}
{{/** TODO: We might end up with
s in the inline editor, fix this **/}}
{{.ContentHtml}}
{{if $.CurrentUser.Loggedin}}
{{.Content}}
{{end}}    {{if $.CurrentUser.Perms.LikeItem}}{{if ne $.CurrentUser.ID .CreatedBy}}{{if .Liked}}{{else}}{{end}}{{end}}{{end}} {{if not $.Topic.IsClosed or $.CurrentUser.Perms.CloseTopic}} {{if $.CurrentUser.Perms.EditReply}}{{end}} {{end}} {{if .Deletable}}{{end}} {{if $.CurrentUser.Perms.ViewIPs}}{{end}} {{if .Tag}}{{.Tag}}{{else}}{{.Level}}{{end}}
{{end}} {{end}}
================================================ FILE: templates/topics.html ================================================ {{template "header.html" . }} {{template "topics_inner.html" . }} {{template "footer.html" . }} ================================================ FILE: templates/topics_inner.html ================================================
{{if not .CurrentUser.Loggedin}}{{end}} {{if .CurrentUser.Loggedin}} {{template "topics_mod_floater.html" . }} {{if .ForumList}} {{/** TODO: Have a seperate forum list for moving topics? Maybe an AJAX forum search compatible with plugin_guilds? **/}} {{/** TODO: Add ARIA attributes for this **/}}
{{lang
{{template "topics_quick_topic.html" . }}
{{end}} {{end}}
{{range .TopicList}}{{template "topics_topic.html" . }}{{else}}
{{lang "topics_no_topics"}}{{if .CurrentUser.Loggedin}}{{if .CurrentUser.Perms.CreateTopic}} {{lang "topics_start_one"}}{{end}}{{end}}
{{end}}
{{template "paginator.html" . }}
================================================ FILE: templates/topics_mini.html ================================================
{{range .Header.NoticeList}}{{template "notice.html" . }}{{end}}
{{template "topics_inner.html" . }}
================================================ FILE: templates/topics_mod_floater.html ================================================ {{/** TODO: Hide these from unauthorised users? **/}}
================================================ FILE: templates/topics_quick_topic.html ================================================
{{if .CurrentUser.Perms.UploadFiles}}
{{end}}
================================================ FILE: templates/topics_topic.html ================================================
{{.Title}} {{if .ForumName}}{{.ForumName}}{{end}}
{{.Creator.Name}} {{/** TODO: Avoid the double '|' when both .IsClosed and .Sticky are set to true. We could probably do this with CSS **/}} {{if .IsClosed}} | 🔒︎{{end}} {{if .Sticky}} | 📍︎{{end}}
{{/** TODO: Phase this out of Cosora and remove it **/}}
{{.PostCount}}
{{.LikeCount}}
{{.PostCount}} {{lang "topic_list.replies_suffix"}} {{.LikeCount}} {{lang "topic_list.likes_suffix"}} {{.ViewCount}} {{lang "topic_list.views_suffix"}}
================================================ FILE: templates/widget_about.html ================================================
{{.Name}} {{.Text}}
================================================ FILE: templates/widget_menu.html ================================================

{{.Name}}

================================================ FILE: templates/widget_online.html ================================================

{{.Name}}

{{if lt .UserCount 30}} {{range .Users}} {{else}}
{{lang "widget.online_none_online"}}
{{end}} {{else}}
{{langf "widget.online_some_online" .UserCount}}
{{end}}
================================================ FILE: templates/widget_search_and_filter.html ================================================
{{range .Forums}} {{end}}
================================================ FILE: templates/widget_simple.html ================================================

{{.Name}}

{{.Text}}
================================================ FILE: themes/cosora/public/account.css ================================================ .sidebar, .footer .widget { display: none; } #account_dashboard .colstack_right .coldyn_block { display: flex; } #account_dashboard .coldyn_item { margin-left: 16px; } #dash_left { border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); background-color: var(--element-background-color); padding: 18px; height: 184px; position: relative; } .dash_security, .account_soon { text-transform: uppercase; font-size: 11px; color: maroon; } #dash_username { display: flex; font-size: 18px; text-align: center; margin-bottom: 6px; } #dash_username input { font-size: 16px; width: 130px; width: 80px; padding-left: 8px; margin-top: -4px; margin-bottom: 6px; margin-left: auto; margin-right: auto; color: hsl(0,0%,45%); /* TODO: Use this colour elsewhere? */ text-align: center; } #dash_username button { display: none; margin-left: 4px; padding: 6px; margin-top: 0px; margin-bottom: 6px; padding-top: 4px; padding-bottom: 4px; } #dash_left img { display: block; border-radius: 48px; height: 72px; width: 72px; margin-left: auto; margin-right: auto; } #dash_left label { display: inline-block; margin-right: 8px; } #dash_avatar_buttons { display: flex; margin-bottom: 3px; } #dash_right { width: 100%; background: none !important; border: none !important; } #dash_right .rowitem { border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); background-color: var(--element-background-color); padding: 16px; } #dash_right .rowitem:not(:last-child) { margin-bottom: 8px; } .validated_email { color: green; } .invalid_email { color: crimson; } ================================================ FILE: themes/cosora/public/convo.css ================================================ .rowhead .rowitem, .convos_list .rowitem { display: flex; } .convos_list .to_left { display: flex; } .convos_list .rowitem img { width: 26px; height: 26px; margin-right: 8px; border-radius: 24px; } .convos_list .rowitem a { /*margin-top: auto; margin-bottom: auto;*/ margin-top: 4px; } .convos_list .to_right { margin-top: auto; margin-bottom: auto; } .convos_item_user:not(:last-child):after { content: ","; } .parti { margin-bottom: 8px; padding: 12px; } .parti .rowitem { display: flex; } .parti_user:not(:last-child):after { content: ","; } .convo_row_box .topRow { display: flex; } .convo_row_box .topRow .controls { padding-top: 16px; padding-right: 16px; } .convo_row_box .content_column { margin-bottom: 16px; } .convo_row_box button { background: inherit; color: var(--lighter-text-color); padding-left: 8px; padding-right: 8px; cursor: pointer; } .convo_row_box button:hover { color: var(--light-text-color); } .convo_row_box button.edit_item:after, .convo_row_box button.delete_item:after, .convo_row_box button.report_item:after { font: normal normal normal 14px/1 FontAwesome; } .convo_row_box button.edit_item:after { content: "\f040"; } .convo_row_box button.delete_item:after { content: "\f1f8"; } .convo_row_box button.report_item:after { content: "\f024"; } .convo_row_box { margin-bottom: 12px; } .convo_row_box:empty { display: none !important; } .convo_row_box .rowitem { background-image: none !important; } .convo_row_box .comment:not(:last-child) { margin-bottom: 8px; } .convo_row_box .comment .userbit { display: flex; margin-left: 14px; margin-top: 14px; margin-bottom: 8px; } .convo_row_box .comment img { width: 40px; height: 40px; border-radius: 62px; margin-right: 8px; } .convo_row_box .comment .nameAndTitle { display: flex; flex-direction: column; margin-top: 2px; } .convo_row_box .comment .nameAndTitle .user_tag { font-size: 15px; } .convo_row_box .comment .content_column { padding-left: 14px; padding-right: 14px; display: flex; width: 100% } .convo_row_box .comment .controls { margin-left: auto; } /*#profile_comments_form .topic_reply_form { border-top: 1px solid var(--element-border-color) !important; }*/ ================================================ FILE: themes/cosora/public/main.css ================================================ :root { --header-border-color: hsl(0,0%,80%); --element-border-color: hsl(0,0%,85%); --element-background-color: white; --replies-lang-string: "{{lang "topics_replies_suffix" . }}"; --topics-lang-string: "{{lang "forums.topics_suffix" . }}"; --likes-lang-string: "{{lang "topics_gap_likes_suffix" . }}"; --primary-link-color: hsl(0,0%,40%); --primary-text-color: hsl(0,0%,20%); --lightened-primary-text-color: hsl(0,0%,30%); --extra-lightened-primary-text-color: hsl(0,0%,40%); --inverse-primary-text-color: white; --light-text-color: hsl(0,0%,55%); --lighter-text-color: hsl(0,0%,65%); --tinted-background-color: hsl(0,0%,98%); } * { box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; } @font-face { font-family: 'FontAwesome'; src: url('../font-awesome-4.7.0/fonts/fontawesome-webfont.eot?v=4.7.0'); src: url('../font-awesome-4.7.0/fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'), url('../font-awesome-4.7.0/fonts/fontawesome-webfont.woff2?v=4.7.0') format('woff2'), url('../font-awesome-4.7.0/fonts/fontawesome-webfont.woff?v=4.7.0') format('woff'), url('../font-awesome-4.7.0/fonts/fontawesome-webfont.ttf?v=4.7.0') format('truetype'), url('../font-awesome-4.7.0/fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg'); font-weight: normal; font-style: normal; } body { font-size: 16px; font-family: arial; margin: 0px; color: var(--lightened-primary-text-color); } a { text-decoration: none; color: var(--primary-link-color); } body, #main { background-color: var(--tinted-background-color); } #back { padding: 8px; padding-top: 0px; display: flex; padding-left: 0px; padding-right: 0px; padding-bottom: 0px; } .footBlock { padding-left: 8px; padding-right: 8px; } #container { background-color: var(--element-background-color); } #main { width: 100%; padding-top: 14px; padding-left: 8px; padding-right: 8px; padding-bottom: 14px; } .sidebar { width: 200px; display: none; } .nav { padding-top: 16px; border-bottom: 1.5px solid var(--header-border-color); } li { margin-right: 12px; } .menu_left:not(:last-child):after, .menu_alerts:after { content: ""; margin-left: 11px; width: 1px; display: inline-block; height: 15px; position: relative; top: 2px; border-right: 1px solid var(--header-border-color); } #menu_overview { font-size: 22px; margin-right: 12px; letter-spacing: 1px; } #menu_overview:after { margin-right: 5px !important; height: 20px !important; } #menu_forums a:before, .menu_topics a:before, .alert_bell:before, .menu_account a:before, .menu_profile a:before, .menu_panel a:before, .menu_logout a:before { font: normal normal normal 14px/1 FontAwesome; } #menu_forums a:before { content: "\f03a"; margin-right: 6px; } .menu_topics a:before { margin-right: 4px; content: "\f27b"; position: relative; top: -2px; } .menu_alerts { color: var(--primary-link-color); display: flex; } .alert_bell:before { content: "\f01c"; } .menu_alerts:not(.has_alerts) .alert_counter { display: none; } .alert_counter { width: 4px; height: 4px; overflow: hidden; background-color: red; opacity: 0.7; border-radius: 30px; position: relative; top: 2px; left: -1px; } .alert_aftercounter:before { content: "{{lang "menu_alerts" . }}"; margin-left: 4px; } .menu_account a:before { content: "\f2c3"; margin-right: 6px; } .menu_profile a:before { content: "\f2c0"; margin-right: 5px; position: relative; top: -1px; font-size: 14px; } .menu_panel a:before { margin-right: 6px; content: "\f108"; } .menu_logout a:before { content: "\f08b"; margin-right: 3px; } #main_menu { display: flex; list-style-type: none; padding: 0px; margin-left: 14px; margin-bottom: 12px; margin-top: 0px; } .menu_alerts:not(.selectedAlert) .alertList { display: none; } .alertList { position: fixed; top: 54px; left: 0px; z-index: 50; background: var(--element-background-color); border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); } .alertList .alertItem { padding: 12px; } .alertItem.withAvatar { background-image: none !important; padding-left: 12px; font-size: 15px; display: flex; } .alertItem.withAvatar .text { margin-top: 8px; } .alertItem.withAvatar:not(:last-child) .text { border-bottom: 1px solid var(--element-border-color); padding-bottom: 16px; } .alertItem .bgsub { width: 32px; height: 32px; border-radius: 30px; margin-right: 12px; } .alertItem.withAvatar:not(:first-child) { padding-top: 0px; } .rowblock, .rowitem, .colstack_head, .colstack_item { position: sticky; } .rowblock, .colstack_head { margin-bottom: 12px; border: 1px solid var(--header-border-color); border-bottom: 2px solid var(--header-border-color); margin-left: 12px; } /* TODO: Reduce the number of nots */ /* TODO: Apply the property to the rowitem on the colstack_head rather than the container itself */ .rowblock:not(.topic_list):not(.forum_list):not(.post_container):not(.topic_reply_container), .colstack_head, .topic_row .rowitem, .forum_list .rowitem { background-color: var(--element-background-color); } .rowblock { margin-right: 12px; } .colstack_right { padding-right: 12px; } .rowhead, .opthead, .colstack_head { padding: 13px; padding-top: 14px; padding-bottom: 14px; } .rowhead:not(:first-child), .opthead:not(:first-child), .colstack_head:not(:first-child) { margin-top: 8px; } .rowhead h1, .opthead h1, .colstack_head h1, .rowhead h2, .opthead h2, .colstack_head h2 { font-size: 19px; font-weight: normal; color: var(--lightened-primary-text-color); display: inline-block; } .rowhead h2, .opthead h2, .colstack_head h2 { font-size: 17px; } .colstack_head a h1 { color: var(--primary-link-color); } .colstack_head.menuhead a h1 { font-size: 16px; } .colstack_head h1 { font-size: 18px; } h1, h2, h3, h4, h5 { -webkit-margin-before: 0; -webkit-margin-after: 0; margin-block-start: 0; margin-block-end: 0; margin-top: 0px; margin-bottom: 0px; } .rowmsg.rowitem { padding: 12px; } .topic_list .rowmsg.rowitem, .forum_list .rowmsg.rowitem { border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); background-color: var(--element-background-color); } .colstack { display: flex; } .colstack:not(#profile_container) .colstack_left { width: 300px; } .colstack:not(#profile_container) .colstack_right { width: 100%; } .extra_little_row_avatar { height: 38px; width: 38px; margin-right: 8px; } .little_row_avatar { height: 48px; width: 48px; } .extra_little_row_avatar, .little_row_avatar { border-radius: 30px; } .mod_floater { position: fixed; bottom: 15px; right: 15px; width: 200px; height: 115px; background-color: var(--inverse-primary-text-color); border: 1px solid var(--header-border-color); border-bottom: 2px solid var(--header-border-color); z-index: 9999; animation: fadein 0.8s; } .mod_floater_head { display: flex; border-bottom: 1px solid var(--element-border-color); margin-left: 16px; margin-right: 16px; margin-bottom: 10px; } .mod_floater_head span { color: hsl(0,0%,55%); font-size: 14px; padding-top: 12px; padding-bottom: 12px; } .mod_floater_body { display: flex; } .mod_floater_body select { margin-left: auto; border-bottom: 1px solid var(--header-border-color); outline: none; } .mod_floater_body button { margin-left: 10px; margin-right: auto; outline: none; padding-left: 10px; background: hsl(9, 97%, 56%); border-radius: 2px; padding-right: 10px; padding-top: 6px; padding-bottom: 6px; color: var(--inverse-primary-text-color); font-size: 13px; font-weight: bold; margin-top: -2px; } .modal_pane { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); background-color: var(--inverse-primary-text-color); border: 1px solid var(--header-border-color); border-bottom: 2px solid var(--header-border-color); /*padding: 8px;*/ padding-left: 24px; padding-right: 24px; z-index: 9999; animation: fadein 0.8s; } .pane_header { color: hsl(0,0%,55%); padding-top: 16px; padding-bottom: 12px; border-bottom: 1px solid var(--element-border-color); margin-bottom: 2px; } .pane_header h3 { font-size: 14px; font-weight: normal; } .pane_row { color: var(--light-text-color); border-bottom: 1px solid var(--element-border-color); font-size: 13px; padding-top: 12px; padding-bottom: 12px; margin-bottom: 3px; cursor: pointer; } .pane_selected { font-weight: bold; } .pane_buttons { padding-top: 12px; padding-bottom: 16px; } @keyframes fadein { from { opacity: 0; } to { opacity: 1; } } .topic_list_title_block { display: flex; } .topic_list_title_block .pre_opt { border-left: 1px solid var(--element-border-color); padding-left: 11px; height: 20px; color: var(--light-text-color); margin-right: 9px; } .topic_list_title_block .pre_opt:before { content: "{{lang "topics_click_topics_to_select" . }}"; font-size: 14px; } .topic_list_title, .forum_title { margin-right: auto; } .mod_opt .moderate_link { border-left: 1px solid var(--element-border-color); padding-left: 12px; height: 20px; color: hsl(0,0%,65%); } .mod_opt .moderate_link:hover { color: var(--light-text-color); } .mod_opt .moderate_link:before { content: "\f0e3"; font: normal normal normal 14px/1 FontAwesome; font-size: 18px; } .mod_opt .moderate_open { display: none; } .filter_opt { display: none; } .auto_hide, .show_on_edit:not(.edit_opened), .hide_on_edit.edit_opened, .show_on_block_edit:not(.edit_opened), .hide_on_block_edit.edit_opened, .link_select:not(.link_opened) { display: none !important; } .topic_create_form { display: flex !important; padding-bottom: 12px; } .topic_create_form .main_form { width: 100%; margin-right: 25px; } .topic_create_form.selectedInput .main_form { margin-right: 50px; margin-left: 18px; } .topic_create_form .topic_meta { display: flex; } .topic_create_form img { display: inline-block; margin-top: 12px; margin-left: 8px; } .topic_board_row, .topic_create_form .quick_button_row { display: none; } .topic_name_row { margin-top: 20px; margin-left: 12px; width: 100%; } #forum_topic_create_form.selectedInput .topic_name_row { margin-left: 20px; } .topic_content_row { display: none; margin-left: 12px; width: 100%; min-width: 0; } .selectedInput .topic_board_row { display: inline-block; margin-top: 16px; margin-left: 12px; } .selectedInput .topic_name_row { margin-top: 16px; margin-bottom: 8px; margin-left: 8px; } .selectedInput .topic_content_row { display: inline-block; } .topic_create_form.selectedInput .quick_button_row { display: inline-block; width: 100%; } .topic_board_row select { height: 27px; width: 100px; margin-left: 10px; } .topic_name_row input, .ip_search_input { width: 100%; display: inline-block; padding-left: 8px; } input, select { border: none; border-bottom: 1px solid var(--header-border-color); outline: none; } .topic_content_row textarea { min-height: 80px; width: 100%; } input[type=checkbox] { display: none; } input[type=checkbox] + label { display: inline-block; width: 12px; height: 12px; margin-bottom: -2px; border: 1px solid var(--element-border-color); background-color: var(--element-background-color); } input[type=checkbox]:checked + label .sel { display: block; width: 5px; height: 5px; background: var(--element-border-color); margin-top: -2px; } .poll_content_row { padding-left: 20px; padding-top: 4px; padding-bottom: 2px; } .poll_content_row .formitem { display: flex; flex-direction: column; } .pollinput:not(:only-child):not(:first-child) { margin-bottom: 5px; } input[type=checkbox] + label.poll_option_label { width: 18px; height: 18px; } input[type=checkbox]:checked + label.poll_option_label .sel { display: block; width: 10px; height: 10px; margin-left: 3px; margin-top: 3px; background: var(--element-border-color); } .poll_option { padding-bottom: 5px; display: flex; } .poll_option_text { display: block; margin-left: 8px; margin-top: 1px; font-size: 15px; position: relative; top: -1px; color: var(--light-text-color); } #poll_option_text_0 { color: #d70206; } #poll_option_text_1 { color: #f05b4f; } .poll_buttons { display: flex; margin-top: 8px; } .poll_buttons button { margin-right: 5px; } .topic_reply_form .pollinput { margin-left: 16px; margin-top: 4px; } .poll_results { margin-left: 14px; } .formbutton { margin-left: auto; margin-right: auto; margin-top: 12px; } .quick_button_row .formitem { display: flex; margin-left: 2px; } .quick_button_row button, .quick_button_row label, .ip_search_search, .formbutton, button { padding-left: 10px; padding-right: 10px; padding-top: 6px; padding-bottom: 6px; color: var(--inverse-primary-text-color); font-size: 13px; font-weight: bold; border-width: initial; border-style: none; border-color: initial; border-image: initial; outline: none; background: hsl(209, 97%, 56%); border-radius: 2px; } .quick_button_row button, .quick_button_row label, .ip_search_search { margin-right: 0px; } .quick_button_row button, .quick_button_row label { margin-left: 10px; margin-top: 8px; } .quick_button_row #add_poll_button { background: hsl(209, 47%, 56%); } .quick_button_row .add_file_button { background: hsl(129, 57%, 56%); } .quick_button_row .close_form { background: hsl(9, 0%, 56%); } .quick_button_row #upload_file_dock { display: flex; } label.uploadItem { background-size: 25px 30px; background-repeat: no-repeat; padding-left: 33px; } select, input, textarea { background: var(--element-background-color); padding: 5px; color: hsl(0,0%,30%); } input, select { color: var(--primary-text-color); } input:not(:focus):not([type="submit"]), select:not(:focus) { color: var(--light-text-color); } textarea { outline: none; border: 1px solid var(--header-border-color); } .topic_reply_container { display: flex; border: 0; } .topic_reply_form { margin: 0px; width: 100%; height: min-content; } .topic_reply_form .formrow { padding: 0px !important; } .topic_reply_form .trumbowyg-button-pane:after { display: none; } .topic_reply_form .trumbowyg-box { min-height: auto; } .topic_reply_form .trumbowyg-editor { border-left: none; border-right: none; min-height: 103px; max-height: 200px; overflow-y: scroll; } .topic_reply_form .quick_button_row { margin-bottom: 7px; } #prevFloat, #nextFloat { display: none; } .topic_list { border: none; } .topic_list .topic_row { display: flex; flex-wrap: wrap; } .topic_list .topic_row:last-child .rowitem { margin-bottom: 0px; } #forum_topic_list .topic_inner_left .starter { display: inline-block; width: 200px; } .rowlist .rowitem, .topic_left, .topic_right { margin-bottom: 8px; padding: 4px; display: flex; border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); } .topic_row.new_item .topic_left, .topic_row.new_item .topic_right { background-color: rgb(239, 255, 255); border: 1px solid rgb(187, 217, 217); border-bottom: 2px solid rgb(187, 217, 217); } .topic_row.new_item .topic_left { border-right: none; } .topic_row.new_item .topic_right { border-left: none; } .hide_ajax_topic { display: none !important; } .topic_middle { display: none; } .rowlist .rowitem { background-color: var(--element-background-color); padding: 12px; } .rowlist.bgavatars { display: grid; grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(auto-fill, 150px); grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); grid-gap: 6px 12px; border: none; background: none !important; } .rowlist .rowitem { display: flex; } .bgavatars .rowitem { background-image: none !important; } .rowlist.bgavatars .rowitem { flex-direction: column; padding-top: 16px; padding-bottom: 10px; } .bgavatars .bgsub { border-radius: 30px; height: 48px; width: 48px; margin-top: 8px; margin-left: 4px; } .rowlist.bgavatars .bgsub { height: 80px; width: 80px; border-radius: 48px; margin-top: 4px; margin-bottom: 8px; } .rowlist.bgavatars .bgsub, .rowlist.bgavatars .rowitem > a, .rowlist.bgavatars .rowitem > span { margin-left: auto; margin-right: auto; } .rowlist .rowTitle { font-size: 20px; margin-bottom: 3px; } .rowlist.bgavatars .rowAvatar { margin-bottom: -4px; } .rowlist .panel_compactrow { padding: 16px; } .loglist .to_left small { margin-left: 2px; font-size: 12px; } .loglist .to_right span { font-size: 14px; } .topic_list .rowtopic { font-size: 16px; margin-right: 1px; white-space: nowrap; display: inline-block; } .topic_list .rowtopic span { max-width: 162px; overflow: hidden; color: var(--primary-text-color); } .topic_list .rowsmall { font-size: 15px; } .topic_list .rowsmall.starter:before { content: "\f007"; font: normal normal normal 14px/1 FontAwesome; margin-right: 5px; font-size: 15px; } .topic_list .lastReplyAt { font-size: 14px; } .topic_list .topic_status_e { display: none; } .topic_left { flex: 1 1 calc(100% - 380px); border-right: none; } .topic_inner_right { margin-left: 15%; margin-right: auto; font-size: 17px; } .rowsmall { font-size: 14px; } .topic_inner_right.rowsmall { margin-top: 15px; } /* Experimenting here */ .topic_inner_right { margin-top: 12px; } .topic_inner_right span { font-size: 16px; } .topic_inner_right span:after { font-size: 13.5px; } /* End Experiment */ .topic_inner_right .replyCount:after { content: var(--replies-lang-string); color: var(--lightened-primary-text-color); } .topic_inner_right .topicCount:after { content: var(--topics-lang-string); color: var(--lightened-primary-text-color); } .topic_inner_right .likeCount:after { content: var(--likes-lang-string); color: var(--lightened-primary-text-color); } .parent_forum { color: var(--lightened-primary-text-color); } .topic_right { flex: 1 1 0px; border-left: none; } .topic_right_inside { display: flex; } .topic_left img { border-radius: 30px; height: 48px; width: 48px; margin-top: 8px; margin-left: 4px; } .topic_right_inside img { border-radius: 30px; height: 42px; width: 42px; margin-top: 10px; } .topic_left .topic_inner_left { margin-top: 12px; margin-left: 8px; margin-bottom: 14px; width: 220px; } .topic_right_inside > span { margin-top: 12px; margin-left: 8px; } .topic_right_inside .lastName { font-size: 14px; } .topic_sticky .topic_left, .topic_sticky .topic_right { border-bottom: 2px solid hsl(51, 60%, 70%); } .topics_moderate .topic_row:not(.can_mod) .topic_left, .topics_moderate .topic_row:not(.can_mod) .topic_right { background-color: #EEEEEE; } .topics_moderate .can_mod:hover .topic_left, .topics_moderate .can_mod:hover .topic_right { background-color: hsl(81, 60%, 97%); } .topic_selected .topic_left, .topic_selected .topic_right { background-color: hsl(81, 60%, 95%); } .level_complete, .level_future, .level_inprogress { display: flex; } .progressWrap { margin-left: auto; width: auto !important; } @element .topic_left .rowtopic and (min-width: 160px) { $this, $this span, $this + .parent_forum { float: left; } $this + .parent_forum { margin: 2px; margin-left: 3px; } $this:after { content: "..."; float: left; } } @element .topic_list and (min-width: 738px) { .topic_left .topic_inner_left { width: calc(240px + 1%); } } @element .topic_list and (min-width: 875px) { .topic_left .topic_inner_left { width: calc(240px + 10%); } } .more_topic_block_initial { display: none; } .more_topic_block_active { display: block; } .forum_list, .post_container { border: none; } .forum_list .rowitem { display: flex; margin-bottom: 8px; border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); padding: 14px; } .forum_list .forum_nodesc { font-style: italic; } .forum_right { display: flex; } .forum_right span { margin-top: 1px; } .shift_right { margin-left: auto; margin-right: 8px; } .topic_item { display: flex; } .topic_item .topic_name_input { width: 100%; padding-left: 12px; margin-right: 12px; } .topic_item .formbutton { margin-top: 0px; } .topic_block { padding-bottom: 10px; } .topic_name_forum_sep { margin-left: 6px; margin-right: 6px; line-height: 26px; font-size: 18px; } .topic_forum { line-height: 24px; } .topic_view_count { margin-left: 6px; font-size: 14px; margin-top: 4px; } .topic_view_count:before { content: "(" } .topic_view_count:after { content: "{{lang "topic.view_count_suffix" . }})"; } .postImage { width: 100%; max-width: 240px; } blockquote { margin: 0px; background-color: #EEEEEE; padding: 12px; margin-top: 12px; margin-bottom: -3px; } blockquote:first-child { margin-top: 0px; } .post_item { display: flex; margin-bottom: 16px; } .userinfo, .content_container { background-color: var(--element-background-color); border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); } .userinfo { margin-right: 16px; display: flex; flex-direction: column; padding-top: 30px; padding-left: 42px; padding-right: 42px; padding-bottom: 18px; height: min-content; } .user_meta { display: flex; flex-direction: column; } .content_container { width: 100%; padding: 17px; display: flex; flex-direction: column; } .avatar_item { background-position: 0px -10px; background-size: 120px; } .avatar_item, .aitem { border-radius: 62px; width: 72px; height: 72px; margin-bottom: 8px; } .the_name, .userinfo .tag_block { margin-left: auto; margin-right: auto; } .the_name { font-size: 18px; color: var(--lightened-primary-text-color); } .action_item .userinfo { display: none; } .action_item .content_container { display: flex; flex-direction: row; } .action_item .action_icon { display: none; } .userinfo .tag_block { color: var(--extra-lightened-primary-text-color); } .post_item .user_content { margin-bottom: 10px; } .user_content h2, .user_content h3 { margin-bottom: 12px; font-weight: normal; } .user_content h4 { margin-bottom: 8px; font-weight: normal; } .user_content strong h2, .user_content strong h3, .user_content strong h4 { font-weight: bold; } red { color: red; } .update_buttons { margin-top: -8px; margin-bottom: 8px; } .update_buttons .add_file_button { margin-left: 8px; } .button_container { margin-top: auto; display: flex; } .action_button { margin-right: 5px; color: var(--light-text-color); font-size: 14px; display: inline-block; } .action_button_left { display: flex; } .action_button_right { display: inline-flex; margin-left: auto; } .like_count { display: none; } .has_likes .like_count { display: block; } .like_count:after { content: "{{lang "topic.like_count_suffix" . }}"; margin-right: 6px; } .post_item .add_like:after, .post_item .remove_like:after, .created_at:before, .ip_item:before { border-left: 1px solid var(--element-border-color); content: ""; margin-top: 1px; margin-bottom: 1px; } .created_at:before, .ip_item:before { margin-right: 10px; } .post_item .add_like:after, .post_item .remove_like:after { margin-left: 10px; margin-right: 5px; } /* TODO: Use a less bold bold */ .post_item .remove_like:before { font-weight: bold; } .created_at { margin-right: 10px; } .add_like:before, .remove_like:before { content: "{{lang "topic.plus_one" . }}"; } .button_container .open_edit:after, .edit_item:after{ content: "{{lang "topic.edit_button_text" . }}"; } .quote_item:after { content: "{{lang "topic.quote_button_text" . }}"; } .delete_item:after { content: "{{lang "topic.delete_button_text" . }}"; } .ip_item_button:after { content: "{{lang "topic.ip_button_text" . }}"; } .lock_item:after { content: "{{lang "topic.lock_button_text" . }}"; } .unlock_item:after { content: "{{lang "topic.unlock_button_text" . }}"; } .pin_item:after { content: "{{lang "topic.pin_button_text" . }}"; } .unpin_item:after { content: "{{lang "topic.unpin_button_text" . }}"; } .report_item:after { content: "{{lang "topic.report_button_text" .}}"; } .attach_edit_bay { margin-top: -4px; } .top_post .attach_edit_bay { margin-top: 8px; } .attach_item { display: flex; margin-top: 4px; margin-bottom: 8px; text-overflow: ellipsis; overflow: hidden; padding: 8px; padding-left: 0px; width: 100%; } .attach_item img { margin-right: 8px; border-radius: 4px; } .attach_item_item { background-color: #EEEEEE; padding-left: 8px; } .attach_item_item span { margin-bottom: 4px; margin-right: auto; padding-top: 4px; overflow: hidden; text-overflow: ellipsis; width: 350px; } .attach_image_holder span { width: 300px; } .attach_item_buttons label, .attach_item_select, .attach_item_delete { margin-left: 0px; margin-right: 8px; margin-top: 0px; } .post_item:not(.has_attachs) .attach_item_buttons, .has_attachs .update_buttons .add_file_button { display: none; } .update_buttons a button { margin-top: 0px; } .top_post .attach_item_buttons { margin-top: -4px; } .zone_view_topic .pageset { margin-bottom: 14px; } .hide_spoil { background-color: lightgrey; color: lightgrey; } .hide_spoil img { border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 50px; white-space: nowrap; width: 1px; background-color: lightgrey; } .hide_spoil img { content: " "; } .attach_box { background-color: #5a5555; background-color: #EFEEEE; border-radius: 3px; padding: 16px; overflow-wrap: break-word; } #ip_search_container .rowlist:not(.has_items) { display: block; } #ip_search_container .rowlist .rowitem { padding-top: 16px; padding-bottom: 10px; } #ip_search_container .rowlist .rowmsg { width: 100%; } .ip_search_block .rowitem { padding: 8px; padding-left: 12px; padding-right: 12px; } .ip_search_input { margin-right: 12px; } .ip_search_block .rowitem, #profile_left_pane .topBlock { display: flex; } #profile_left_lane { margin-left: 8px; margin-right: 4px; } #profile_left_pane .topBlock { flex-direction: column; padding-bottom: 12px; border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); background-color: var(--element-background-color); } #profile_left_pane .avatarRow { padding: 28px; padding-bottom: 4px; padding-top: 22px; } #profile_left_pane .avatar { border-radius: 80px; height: 100px; width: 100px; } #profile_left_pane .nameRow { display: flex; flex-direction: column; margin-left: auto; margin-right: auto; } #profile_left_pane .nameRow .username { text-align: center; } #profile_left_pane .profileName { font-size: 19px; } .rowmenu .passive { border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); background-color: var(--element-background-color); margin-top: 6px; padding: 12px; padding-top: 10px; padding-bottom: 10px; } .colstack:not(#profile_container) .rowmenu { padding-left: 12px; } .colstack:not(#profile_container) .rowmenu .passive { margin-top: 0px; border-bottom: none; } .colstack:not(#profile_container) .rowmenu .passive:last-child { border-bottom: 2px solid var(--element-border-color); } #profile_left_pane .passiveBlock .passive { padding-left: 12px; } #profile_right_lane { width: 100%; margin-right: 12px; } .colstack_right .colstack_item:not(.rowlist):not(#profile_comments), #profile_comments .comment, .alert { border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); background-color: var(--element-background-color); } .alert { padding: 12px; margin-top: -3px; margin-bottom: 8px; margin-left: 12px; margin-right: 12px; } .colstack_right .alert { margin-left: 16px; margin-right: 0px; } .colstack_right .colstack_item, .colstack_right .colstack_grid { margin-left: 16px; } #profile_right_lane .topic_reply_form { width: auto; } #profile_comments .topRow { display: flex; } #profile_comments .topRow .controls { padding-top: 16px; padding-right: 16px; } #profile_comments .content_column { margin-bottom: 16px; } #profile_comments button { background: inherit; color: var(--lighter-text-color); padding-left: 8px; padding-right: 8px; cursor: pointer; } #profile_comments button:hover { color: var(--light-text-color); } #profile_comments button.edit_item:after, #profile_comments button.delete_item:after, #profile_comments button.report_item:after { font: normal normal normal 14px/1 FontAwesome; } #profile_comments button.edit_item:after { content: "\f040"; } #profile_comments button.delete_item:after { content: "\f1f8"; } #profile_comments button.report_item:after { content: "\f024"; } #profile_comments_head { margin-top: 6px; } #profile_comments { margin-bottom: 12px; } #profile_comments:empty { display: none !important; } #profile_comments .rowitem { background-image: none !important; } #profile_comments .comment:not(:last-child) { margin-bottom: 8px; } #profile_comments .comment .userbit { display: flex; margin-left: 14px; margin-top: 14px; margin-bottom: 8px; } #profile_comments .comment img { width: 40px; height: 40px; border-radius: 62px; margin-right: 8px; } #profile_comments .comment .nameAndTitle { display: flex; flex-direction: column; margin-top: 2px; } #profile_comments .comment .nameAndTitle .user_tag { font-size: 15px; } #profile_comments .comment .content_column { padding-left: 14px; padding-right: 14px; display: flex; width: 100% } #profile_comments .comment .controls { margin-left: auto; } #profile_comments_form .topic_reply_form { border-top: 1px solid var(--element-border-color) !important; } .formitem:only-child { width: 100%; display: flex; } .the_form .formitem:only-child button { margin-left: auto; margin-right: auto; } .quick_reply_form, .topic_reply_form, .the_form { background: var(--element-background-color); } .formrow { border-right: none !important; } .to_right { float: right; margin-left: auto; } #account_edit_avatar .avatar_box { margin-bottom: 10px; } #create_topic_page .close_form, #create_topic_page .formlabel, #login_page .formlabel { display: none; } .formrow:not(:first-child):not(:last-child) { margin-top: 4px; } .formrow:not(:first-child) { padding-top: 3px; } .formrow { padding: 16px; } .formrow:not(:last-child) { padding-bottom: 4px; } #login_page .formrow:not(:last-child) { padding-bottom: 0px; } .formlabel { display: block; font-size: 15px; } .quick_create_form .formrow { padding: 0px; } #register_page .register_button_row { padding: 12px !important; padding-top: 0px !important; margin-top: -2px !important; } #register_page .register_button_row .formbutton { margin-left: 2px; } /* TODO: Add a generic button_row class and add this to them all? */ .login_button_row { display: flex; } .dont_have_account, .forgot_password { color: var(--primary-link-color); font-size: 12px; margin-top: 23px; } .dont_have_account { margin-left: auto; } .dont_have_account:after { content: "|"; margin-left: 5px; margin-right: 5px; } /* TODO: Highlight the one we're currently on? */ .pageset { display: flex; margin-left: 14px; } .pageitem { padding: 8px; padding-left: 10px; padding-right: 10px; background: var(--element-background-color); border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); border-left: none; border-right: none; } .pageitem:first-child { border-left: 1px solid var(--element-border-color); } .pageitem:last-child { border-right: 1px solid var(--element-border-color); } .pagefirst, .pagenext, .pageprev, .pagelast { padding-top: 5px; } .pagefirst a, .pagenext a, .pageprev a, .pagelast a { font-size: 18px; } /* TODO: Make widget_about's CSS less footer centric */ .footerBit, .footer .widget { border-top: 1px solid var(--element-border-color); padding: 12px; padding-top: 10px; padding-bottom: 10px; margin-left: -8px; margin-right: -8px; background-color: var(--element-background-color); display: flex; } .elapsed { display: none; } #poweredByHolder { border-bottom: 2px solid var(--element-border-color); } .about, #poweredBy { font-size: 17px; display: flex; flex-direction: column; } #poweredBy { margin-right: auto; } #poweredBy span { font-size: 16px; } #aboutTitle { font-size: 17px; margin: 8px; margin-bottom: 4px; } #poweredByName { font-size: 17px; margin: 4px; } #aboutDesc { margin-left: 8px; margin-top: 8px; width: 60%; font-size: 16px; } #aboutDesc p { -webkit-margin-before: 12px; -webkit-margin-after: 12px; } #aboutDesc p:last-child { -webkit-margin-after: 8px; } #aboutDesc p:first-child { -webkit-margin-before: 0px; } #poweredByDash, #poweredByMaker { display: none; } #themeSelectorSelect { padding: 3px; margin-top: 0px; } .colstack_grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-gap: 8px; } .grid_item { background: var(--element-background-color); border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); margin: 0px; padding: 16px; padding-left: 0px; display: flex; padding-top: 0px; padding-bottom: 0px; padding-right: 10px; } .grid_item span, .grid_item a { margin-top: 16px; margin-bottom: 16px; margin-left: auto; margin-right: auto; text-align: center; } @media(min-width: 1000px) { .footer { margin-left: -8px; margin-right: -8px; } .footerBit, .footer .widget { border-top: 1px solid var(--header-border-color); border-left: 1px solid var(--header-border-color); border-right: 1px solid var(--header-border-color); } #poweredByHolder { border-bottom: 2px solid var(--header-border-color); } #main { max-width: 1000px; margin-left: auto; margin-right: auto; padding-top: 18px; padding-left: 16px; padding-right: 16px; border-left: 1px solid hsl(20,0%,95%); border-right: 1px solid hsl(20,0%,95%); } .footer { max-width: 1000px; margin-left: auto; margin-right: auto; padding-left: 8px; padding-right: 8px; } #back, .footer, .footBlock { background-color: hsl(0,0%,95%); } #back:not(.zone_panel) .footBlock { display: flex; } } @media(min-width: 721px) { .hide_on_big { display: none; } .postIframe { min-width: 400px; min-height: 200px; } } @media(max-width: 720px) { .menu_profile, .ip_item { display: none; } .like_count { margin-right: 1px; } .like_count:after, .created_at:before, .ip_item:before { margin-right: 6px; } } @media(max-width: 670px) { .topic_inner_right { display: none; } } @media(max-width: 620px) { .userinfo .avatar_item { width: 72px; height: 72px; } } @media(max-width: 610px) { .userinfo { padding-top: 24px; padding-left: 34px; padding-right: 34px; padding-bottom: 14px; } .userinfo .avatar_item { height: 64px; width: 64px; /*background-size: 82px;*/ } } @media(max-width: 590px) { #main { padding-left: 4px; padding-right: 4px; } .post_item { margin-bottom: 12px; } .userinfo { margin-right: 12px; padding-top: 20px; padding-left: 24px; padding-right: 24px; padding-bottom: 12px; } .userinfo .avatar_item { width: 52px; height: 52px; margin-bottom: 10px; background-size: 72px; margin-left: auto; margin-right: auto; } .post_tag { font-size: 15px; } .content_container { padding: 15px; } } @media(max-width: 550px) { .nav { border-bottom: 1px solid var(--header-border-color); } .menu_profile { display: block; } #menu_overview { font-size: 18px; background-color: hsl(0,0%,97%); margin-top: -16px; margin-bottom: -11px; margin-left: -14px; margin-right: 16px; padding-top: 16px; padding-left: 14px; padding-right: 4px; } #menu_overview::after { height: 16px !important; } .menu_left a:after { content: "" !important; } .menu_left:not(:last-child):after, .menu_alerts:after { margin-left: 4px; border-right: none; } .menu_alerts { margin-right: 16px; } .alert_bell { position: relative; bottom: -1px; } .alert_bell:before { font-size: 17px; } .alert_aftercounter { display: none; } #back { padding-top: 0px; } .rowhead h1, .opthead h1, .colstack_head h1 { font-size: 18px; } main > .rowhead, #main > .rowhead { border: none; border-bottom: 2px solid var(--header-border-color); } #main { padding-top: 0px; } main > .rowhead, #main > .rowhead, main > .opthead, #main > .opthead { margin-left: -3px; margin-right: -3px; } .topic_list { display: flex; flex-wrap: wrap; } .topic_list .topic_row { display: block; width: calc(50% - 6px); float: left; } .topic_list .topic_row:nth-child(odd) { margin-right: 12px; } .topic_left { margin-bottom: 0px; border-bottom: none; border-right: 1px solid var(--element-border-color); } .topic_left .parent_forum { display: none; } .topic_right.rowitem { border-top: none; border-left: 1px solid var(--element-border-color); background-color: hsl(0,0%,95%); } .topic_right_inside br, .topic_right_inside img { display: none; } .topic_sticky .topic_right { border-bottom: 2px solid var(--element-border-color); } .topic_right_inside > span { margin-top: 6px; margin-bottom: 6px; } .topic_name_forum_sep { line-height: 22px; font-size: 18px; } .topic_forum { line-height: 20px; } .button_container { border-top: 1px solid var(--element-border-color); } .action_button { padding-bottom: 15px; padding-left: 10px; padding-right: 8px; padding-top: 15px; font-size: 12px; } .action_button:not(.add_like):not(.remove_like) { font: normal normal normal 14px/1 FontAwesome; } .has_likes .action_button_right { margin-left: 0px; width: 100%; } .like_item { background-color: hsl(0,0%,97%); } .post_item:not(.top_post) .like_item { border-bottom: 1px solid var(--element-border-color); } .post_item .add_like:after, .post_item .remove_like:after { border-left: none; margin: inherit; } .content_container { padding: 0px; } .post_item .user_content { padding: 12px; margin-bottom: 0px; } .button_container .open_edit:after, .edit_item:after{ content: "\f040"; } .delete_item:after { content: "\f014"; } .ip_item_button:after { content: "\f0ac"; } .lock_item:after { content: "\f023"; } .unlock_item:after { content: "\f09c"; } .pin_item:after, .unpin_item:after { content: "\f08d"; } .report_item:not(.profile_menu_item):after { content: "\f024"; } .unpin_item, .unlock_item { background-color: hsl(80,50%,97%); } .like_count, .like_count:before { font-family: arial; } .like_count:after { content: ""; } .like_count:before { content: "{{lang "topic.plus" . }}"; font-weight: normal; } .created_at { margin-left: auto; } .created_at:before { border-left: none; margin: inherit; } .topic_reply_form .trumbowyg-editor { padding: 15px; } .trumbowyg-editor[contenteditable=true]:empty:not(:focus)::before { font-size: 15px; } .trumbowyg-button-pane .trumbowyg-button-group:first-child { margin-left: 0px !important; } .trumbowyg-button-pane .trumbowyg-button-group:after, .trumbowyg-button-pane .trumbowyg-button-group:first-child:before { margin: inherit !important; border: none !important; } } @media(min-width: 521px) { .button_menu { display: none; } } @media(max-width: 520px) { .edit_item, .button_container .open_edit, .delete_item, .pin_item, .unpin_item, .lock_item, .unlock_item, .ip_item_button, .report_item:not(.profile_menu_item) { display: none; } .button_menu:after { content: "\f013"; } .button_menu_pane { display: flex; flex-direction: column; background-color: var(--element-background-color); border: 2px solid var(--element-border-color); position: fixed; left: 50%; top: 110px; width: 300px; transform: translateX(-50%); z-index: 200; } .button_menu_pane > *:not(:last-child) { border-bottom: 1px solid var(--element-border-color); } .button_menu_pane .userinfo { display: flex; flex-direction: row; width: 100%; padding-top: 12px; } .button_menu_pane .avatar_item { width: 42px; height: 42px; background-size: 62px; margin-left: 0px; margin-right: 10px; margin-bottom: 0px; } .button_menu_pane .userinfo .the_name { margin-right: 0px; } /* TODO: Make this grid more flexible so that plugins can add new items more easily */ .button_menu_pane .buttonGrid { display: grid; grid-template-columns: repeat(8, 1fr); border-left: 1px solid var(--element-border-color); border-bottom: 1px solid var(--element-border-color); } .button_menu_pane .action_button { display: flex; margin: 0px; padding-left: 0px; padding-right: 0px; background-color: var(--element-background-color); margin-left: auto; margin-right: auto; width: 42px; height: 42px; font-size: 15px; border-right: 1px solid var(--element-border-color); border-bottom: 1px solid var(--element-border-color); } .button_menu_pane .action_button:nth-child(8n) { border-right: none; } .button_menu_pane .action_button:nth-last-child(-n+8) { border-bottom: none; } .button_menu_pane .action_button:after, .button_menu_pane .add_like:before, .button_menu_pane .remove_like:before { margin-left: auto; margin-right: auto; } .button_menu_pane .open_edit:after { content: "\f040"; } .button_menu_pane .gridFiller { background-color: var(--tinted-background-color); } } @media(max-width: 450px) { .topic_list .topic_row { display: block; width: 100%; float: none; } .topic_list .topic_row:nth-child(odd) { margin-right: 0px; } } @media(max-width: 440px) { #main { padding-left: 0px; padding-right: 0px; } .userinfo { padding-left: 18px; padding-right: 18px; margin-right: 10px; } .the_name { font-size: 17px; } } ================================================ FILE: themes/cosora/public/misc.js ================================================ "use strict"; (() => { function newElement(etype, eclass) { let element = document.createElement(etype); element.className = eclass; return element; } function moveAlerts() { // Move the alerts under the first header let colSel = $(".colstack_right .colstack_head:first"); let colSelAlt = $(".colstack_right .colstack_item:first"); let colSelAltAlt = $(".colstack_right .coldyn_block:first"); if(colSel.length > 0) $('.alert').insertAfter(colSel); else if (colSelAlt.length > 0) $('.alert').insertBefore(colSelAlt); else if (colSelAltAlt.length > 0) $('.alert').insertBefore(colSelAltAlt); else $('.alert').insertAfter(".rowhead:first"); } addInitHook("end_init", () => { let loggedIn = document.head.querySelector("[property='x-mem']")!=null; if(loggedIn) { if(navigator.userAgent.indexOf("Firefox")!=-1) $.trumbowyg.svgPath = pre+"trumbowyg/ui/icons.svg"; // Is there we way we can append instead? Maybe, an editor plugin? attachItemCallback = function(attachItem) { let currentContent = $('#input_content').trumbowyg('html'); $('#input_content').trumbowyg('html',currentContent); } quoteItemCallback = function() { let currentContent = $('#input_content').trumbowyg('html'); $('#input_content').trumbowyg('html',currentContent); } $(".topic_name_row").click(() => { $(".topic_create_form").addClass("selectedInput"); }); // TODO: Bind this to the viewport resize event var btnlist = []; if(document.documentElement.clientWidth > 550) { btnlist = [['viewHTML'],['undo','redo'],['formatting'],['strong','em','del'],['link'],['insertImage'],['unorderedList','orderedList'],['removeformat']]; } else { btnlist = [['viewHTML'],['strong','em','del'],['link'],['insertImage'],['unorderedList','orderedList'],['removeformat']]; } $('.topic_create_form #input_content').trumbowyg({ btns: btnlist, }); $('.topic_reply_form #input_content').trumbowyg({ btns: btnlist, autogrow: true, }); $('#profile_comments_form .topic_reply_form .input_content').trumbowyg({ btns: [['viewHTML'],['strong','em','del'],['link'],['insertImage'],['removeformat']], autogrow: true, }); addHook("edit_item_pre_bind", () => { $('.user_content textarea').trumbowyg({ btns: btnlist, autogrow: true, }); }); } // TODO: Refactor this to use `each` less $('.button_menu').click(function(){ log(".button_menu"); // The outer container let buttonPane = newElement("div","button_menu_pane"); let postItem = $(this).parents('.post_item'); // Create the userinfo row in the pane let userInfo = newElement("div","userinfo"); postItem.find('.avatar_item').each(function(){ userInfo.appendChild(this); }); let userText = newElement("div","userText"); postItem.find('.userinfo:not(.avatar_item)').children().each(function(){ userText.appendChild(this); }); userInfo.appendChild(userText); buttonPane.appendChild(userInfo); // Copy a short preview of the post contents into the pane postItem.find('.user_content').each(function(){ // TODO: Truncate an excessive number of lines to 5 or so let contents = this.innerHTML; if(contents.length > 45) this.innerHTML = contents.substring(0,45) + "..."; buttonPane.appendChild(this); }); // Copy the buttons from the post to the pane let buttonGrid = newElement("div","buttonGrid"); let gridElementCount = 0; $(this).parent().children('a:not(.button_menu)').each(function(){ buttonGrid.appendChild(this); gridElementCount++; }); // Fill in the placeholder grid nodes let rowCount = 4; log("rowCount",rowCount); log("gridElementCount",gridElementCount); if(gridElementCount%rowCount != 0) { let fillerNodes = (rowCount - (gridElementCount%rowCount)); log("fillerNodes",fillerNodes); for(let i = 0; i < fillerNodes;i++ ) { log("added a gridFiller"); buttonGrid.appendChild(newElement("div","gridFiller")); } } buttonPane.appendChild(buttonGrid); document.getElementById("back").appendChild(buttonPane); }); moveAlerts(); }); addInitHook("after_notice", moveAlerts); })() ================================================ FILE: themes/cosora/public/panel.css ================================================ #main { max-width: inherit; margin-left: 0px; margin-right: 0px; border-left: none; border-right: none; padding-left: 0px; padding-bottom: 0px; } #back { background-color: inherit; padding-top: 0px; } .colstack_left { width: 250px !important; background-color: white; margin-top: -18.5px; margin-left: -0.5px; border-top: 1px solid var(--element-border-color); border-right: 1px solid var(--element-border-color); } .colstack_left .colstack_head { margin-top: 6px; margin-bottom: 8px; margin-left: 16px; padding-bottom: 12px; padding-top: 12px; padding-left: 8px; border-bottom: 1px solid var(--element-border-color); border-left: none; border-right: none; border-top: none; width: fit-content; } .colstack_left .rowmenu { padding-left: 18px !important; } .colstack_left .rowmenu .passive { border: none; font-size: 15px; padding: 8px; padding-top: 6px; padding-bottom: 8px; } .colstack_left .rowmenu .passive:first-child { border-top: none; } .colstack_left .rowmenu .passive:last-child { border-bottom: 1px solid var(--element-border-color) !important; width: 150px; padding-bottom: 16px; } .submenu { margin-left: 12px; } .submenu_fallback { display: none; } .colstack_right { margin-right: 14px; /*margin-top: -4px;*/ margin-bottom: 14px; } .colstack_right .colstack_head .rowitem { display: flex; } .colstack_right .colstack_head h1 + h2.hguide { margin-left: auto; color: var(--extra-lightened-primary-text-color); } .footer { margin-top: 0px; } .colstack_right .colstack_head:not(:first-child) { margin-top: 14px; } #panel_dashboard_right .colstack_head h1 { font-size: 17px; color: hsl(0,0%,40%); } /* TODO: Move these to panel.css */ #dash-version:before, #dash-cpu:before, #dash-ram:before, #dash-memused:before, #dash-totonline:before, #dash-gonline:before, #dash-uonline:before, #dash-reqs:before, #dash-postsperday:before, #dash-topicsperday:before { display: inline-block; background: var(--tinted-background-color); font: normal normal normal 14px/1 FontAwesome; font-size: 20px; padding-left: 17px; padding-top: 16px; padding-right: 19px; color: hsl(0,0%,20%); } #dash-version:before { content: "\f126"; } #dash-cpu:before, #dash-memused:before { content: "\f2db"; } #dash-ram:before { content: "\f233"; } #dash-totonline:before, #dash-gonline:before, #dash-uonline:before { content: "\f007"; } #dash-reqs:before { content: "\f080"; } #dash-postsperday:before, #dash-topicsperday:before { content: "\f27b"; } .grid2 { margin-top: 16px; } #panel_debug .grid_stat:not(.grid_stat_head) { margin-bottom: 8px; } .debug_page.colstack_right .colstack_sub_head { margin-top: 6px; } .complex_rowlist { background-color: inherit !important; border: none !important; } .complex_rowlist .rowitem { display: flex; } .panel_buttons, .panel_floater { margin-left: auto; display: flex; } .panel_buttons:before, .panel_floater:before, .edit_button:after, .delete_button:after { color: hsl(0,0%,65%); } .panel_buttons:before, .panel_floater:before { content: ""; border-left: 1px solid var(--element-border-color); height: 15px; margin-top: 2px; margin-bottom: 0px; margin-right: 0px; } #panel_users .panel_floater:before { display: none; } #panel_users .panel_floater { text-align: center; flex-direction: column; } .edit_button:after, .delete_button:after { font: normal normal normal 14px/1 FontAwesome; padding-left: 12px; height: 20px; } .edit_button:after { content: "\f040"; } .delete_button:after { content: "\f014"; } #panel_users .panel_floater:before, #panel_forums .panel_floater:before, #forum_quick_perms .panel_floater:before, .panel_themes .panel_floater:before, #panel_backups .panel_floater:before { border-left: none; } #panel_forums_name_box { order: 0; } #panel_users .panel_tag:not(.panel_right_button) { display: none; } .panel_right_button + .panel_right_button { margin-left: 3px; } .panel_group_perms .formitem a { margin-top: 5px; } #panel_word_filters .itemSeparator:before { content: "|"; padding-left: 5px; padding-right: 5px; color: var(--primary-link-color); } /* TODO: Should we be using .formrows in #forum_quick_perms? Can we normalize it? Would this break the other themes? */ .formlist:not(#forum_quick_perms), .panel_themes .rowitem, #panel_plugins .rowitem, #forum_quick_perms .formitem { padding: 12px; margin-bottom: 8px; background-color: var(--element-background-color); border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); } .formlist .formrow { padding: 0px !important; margin: 0px; } .formlist .formitem { padding: 8px; } .panel_theme_mobile, .panel_theme_tag { display: none; } #panel_plugins .rowitem { display: block; } #panel_users .rowitem .rowTitle { border-bottom: 1px solid var(--lighter-text-color); padding-bottom: 4px; margin-bottom: 6px; } #panel_setting textarea, #panel_page_list textarea, #panel_page_edit textarea { width: 100%; height: 80px; } #panel_setting .formlabel { display: none; } #panel_settings .panel_upshift { border-bottom: 1px solid var(--element-border-color); padding-bottom: 12px; } #panel_settings.rowlist.bgavatars .rowitem > a, #panel_settings.rowlist.bgavatars .rowitem > span { margin-left: 0px; margin-right: 0px; } #panel_settings .to_right { float: none; margin-top: auto; padding-top: 14px; word-break: break-all; } #panel_page_list textarea, #panel_page_edit textarea { margin-top: 8px; } #forum_quick_perms .formitem { display: flex; } #forum_quick_perms .formitem .edit_fields { margin-left: 3px; margin-top: 1px; } #forum_quick_perms .perm_preset { margin-right: 6px; } .perm_preset_no_access:before { content: "{{lang "panel_perms_no_access" . }}"; color: hsl(0,100%,20%); } .perm_preset_read_only:before, .perm_preset_can_post:before { color: hsl(120,100%,20%); } .perm_preset_read_only:before { content: "{{lang "panel_perms_read_only" . }}"; } .perm_preset_can_post:before { content: "{{lang "panel_perms_can_post" . }}"; } .perm_preset_can_moderate:before { content: "{{lang "panel_perms_can_moderate" . }}"; color: hsl(240,100%,20%); } .perm_preset_quasi_mod:before { content: "{{lang "panel_perms_quasi_mod" . }}"; } .perm_preset_custom:before { content: "{{lang "panel_perms_custom" . }}"; color: hsl(0,0%,20%); } .perm_preset_default:before { content: "{{lang "panel_perms_default" . }}"; } .panel_submitrow .rowitem { display: flex; } .panel_submitrow .rowitem *:first-child { margin-left: auto; } .panel_submitrow .rowitem *:last-child { margin-right: auto; } .panel_submitrow .rowitem button { margin-top: 0px; } .colstack_graph_holder { background-color: var(--element-background-color); border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); margin-left: 16px; padding-top: 16px; } .colstack_graph_holder.scrolly { overflow-x: scroll; width: 800px; } .colstack_graph_holder.scrolly .ct_chart { width: 1000px; } .ct-label { fill: rgba(0,0,0,.6) !important; color: rgba(0,0,0,.6) !important; } .ct-grid { stroke: rgba(0,0,0,.3) !important; } .ct-series-a .ct-bar, .ct-series-a .ct-line, .ct-series-a .ct-point, .ct-series-a .ct-slice-donut { stroke: hsl(359,98%,53%) !important; } .ct-series-a.ct-point { stroke: hsl(359,98%,23%) !important; } .ct-series-a.ct-point:hover { stroke: hsl(359,98%,30%) !important; } .ct-legend .ct-series-7:before { background-color: #6b0392 !important; border-color: #6b0392 !important; } /*.ct-chart-line { height: 300px !important; }*/ /*.ct-label.ct-horizontal { position: fixed; justify-content: flex-end; text-align: right; transform-origin: 100% 0; transform: translate(-100%) rotate(-90deg); white-space: nowrap; /*padding-right: 48px;/ top: 8px; }*/ /*.ct-label.ct-horizontal { white-space: nowrap; }*/ /*.ct-label.ct-horizontal { position: fixed; justify-content: flex-end; text-align: right; transform-origin: 100% 0; transform: translate(-100%) rotate(-45deg); white-space: nowrap; }*/ .ct-legend { margin-top: 0px; margin-bottom: 0px; } .analytics .colstack_head select { margin-top: -5px; } .colstack_graph_holder + .rowlist { margin-top: 8px; } .analytics .colstack_head h1 { margin-top: 2px; } select + .timeRangeSelector { margin-left: 8px; } /* Experimental header tweaks */ .colstack_head a { font-size: 17px; } /* #panel_analytics_views_table .rowlist .panel_compactrow { padding: 14px; font-size: 16px; } */ .widget_normal { display: flex; width: 100%; } #widgetTmpl, .widget_disabled { display: none; } .bg_red .widget_disabled { display: inline; } .wtypes .formrow { display: none; } .wtype_about .w_about, .wtype_simple .w_simple, .wtype_wol .w_wol, .wtype_default .w_default { display: block; } .panel_widgets { margin-bottom: 18px; } #panel_reglogs .panel_compactrow { flex-direction: column; } .logdetail { display: flex; width: 100%; margin-top: 4px; } #panel_reglogs .logdetail small, #panel_reglogs .logdetails span { font-size: 14px; } .pageset { margin-left: 16px; } @media(max-width: 999px) { .colstack_left { margin-top: -14.5px; } } @media(min-width: 1000px) { .footBlock { padding-left: 0px; padding-right: 0px; } .footer { max-width: none; width: 100%; margin-left: 0px; margin-right: 0px; } } ================================================ FILE: themes/cosora/public/profile.css ================================================ .colline { border: 1px solid var(--element-border-color); border-bottom: 2px solid var(--element-border-color); background-color: var(--element-background-color); padding: 8px; margin-left: 16px; margin-bottom: 12px; } .userbit > a { height: 40px; } ================================================ FILE: themes/cosora/theme.json ================================================ { "Name": "cosora", "FriendlyName": "Cosora", "Version": "0.1.0", "Creator": "Azareal", "URL": "github.com/Azareal/Gosora", "Tag": "WIP", "Docks":["topMenu","rightSidebar","footer"], "Templates": [ { "Name": "topic", "Source": "topic_alt" }, { "Name": "topic_mini", "Source": "topic_alt_mini" } ], "Resources": [ { "Name":"EQCSS.js", "Location":"global" }, { "Name":"trumbowyg/trumbowyg.min.js", "Location":"global", "Loggedin":true }, { "Name":"trumbowyg/ui/trumbowyg.custom.css", "Location":"global", "Loggedin":true }, { "Name":"cosora/misc.js", "Location":"global", "Async":true } ] } ================================================ FILE: themes/nox/overrides/login.html ================================================ {{template "header.html" . }}

{{lang "login_head"}}

{{template "footer.html" . }} ================================================ FILE: themes/nox/overrides/panel_before_head.html ================================================
{{lang "panel_welcome"}}{{.CurrentUser.Name}}
================================================ FILE: themes/nox/overrides/panel_group_menu.html ================================================ ================================================ FILE: themes/nox/overrides/panel_inner_menu.html ================================================
{{if .CurrentUser.Perms.ManagePlugins}}{{end}} {{if .CurrentUser.IsSuperAdmin}}{{end}} {{if .CurrentUser.IsAdmin}} {{if .DebugAdmin}}{{end}} {{end}}
================================================ FILE: themes/nox/overrides/panel_menu.html ================================================ ================================================ FILE: themes/nox/overrides/profile_comments_row.html ================================================ {{template "profile_comments_row_alt.html" . }} ================================================ FILE: themes/nox/overrides/topics_topic.html ================================================
{{.PostCount}} {{lang "topic_list.replies_suffix"}} {{.LikeCount}} {{lang "topic_list.likes_suffix"}} {{.ViewCount}} {{lang "topic_list.views_suffix"}} {{.WeekViews}} {{lang "topic_list.views_suffix"}}
================================================ FILE: themes/nox/public/acc_panel_common.css ================================================ #main { max-width: none !important; } .colstack_left { width: 200px; padding-bottom: 6px; background-color: rgb(62, 62, 62); /*border-left: 4px solid rgb(82, 82, 82);*/ } .colstack_left .colstack_head { /*font-size: 19px;*/ font-size: 18px; margin-bottom: 8px; background-color: rgb(72, 72, 72); /*padding-top: 10px;*/ padding-top: 9px; padding-left: 18px; padding-right: 24px; /*padding-bottom: 10px;*/ padding-bottom: 9px; margin-left: 0px; } .colstack_left .colstack_head:not(:first-child) { margin-top: 14px; font-size: 18px; padding-top: 9px; padding-bottom: 9px; } .colstack_left .colstack_head a { color: rgb(210, 210, 210); } .rowmenu { margin-left: 18px; /*margin-bottom: 2px;*/ margin-bottom: 3px; font-size: 17px; } .rowmenu a { color: rgb(180, 180, 180); } .rowmenu .rowitem { /*margin-bottom: 4px;*/ margin-bottom: 6px; } .to_right { margin-left: auto; } .bg_red { background-color: rgb(88,68,68) !important; } @media (max-width: 420px) { .colstack { display: block; } .colstack_left, .colstack_right { width: auto !important; } } ================================================ FILE: themes/nox/public/account.css ================================================ .sidebar, .footer .widget { display: none; } /* start panel css copy, try to de-dupe this */ #back { padding: 0px; } {{template "acc_panel_common.css" }} .colstack_right { background-color: #333333; width: 75%; padding-right: 24px; padding-bottom: 24px; padding-left: 24px; } .colstack_right .colstack_head { margin-bottom: 6px; } .colstack_right .colstack_head h1 { font-size: 21px; } .footer .widget, #poweredByHolder { background-color: #393939; } /* end panel css copy */ .colstack_right { padding-top: 16px; } .account_soon, .dash_security { font-size: 14px; color: rgb(270, 170, 170); } #account_dashboard .colstack_right .coldyn_block { display: flex; margin-top: 2px; } #dash_left { border-radius: 3px; background-color: #444444; padding: 12px; height: 180px; width: 240px; position: relative; } #dash_username { display: flex; } #dash_username input { display: block; margin-left: auto; margin-right: auto; margin-bottom: 8px; width: 100px; display: relative; padding-left: 16px; background-position: right 8px bottom 8px; } #dash_username button { margin-bottom: 8px; padding-top: 2px; padding-bottom: 2px; } #dash_left img { display: block; border-radius: 48px; height: 72px; width: 72px; margin-left: auto; margin-right: auto; margin-bottom: 12px; } #dash_avatar_buttons { display: flex; } #dash_avatar_buttons label { margin-left: auto; margin-right: 8px; } #dash_avatar_buttons button { margin-right: auto; } #revoke_avatars { margin-left: auto; } #dash_right { width: 100%; margin-left: 12px; } #dash_right .rowitem { border-radius: 3px; background-color: #444444; padding: 16px; } #dash_right .rowitem:not(:last-child) { margin-bottom: 8px; } .rowlist .rowitem { display: flex; } .validated_email { color: rgb(0, 170, 0); } .invalid_email { color: crimson; } ================================================ FILE: themes/nox/public/convo.css ================================================ .rowhead .rowitem, .convos_list .rowitem { display: flex; } .convo_create_form { margin-bottom: 8px; } .close_form { margin-left: 8px; } .convos_list .to_left { display: flex; } .convos_list .rowitem img { width: 24px; height: 24px; margin-right: 8px; border-radius: 24px; } .convos_list .rowitem a { /*margin-top: auto; margin-bottom: auto;*/ } .convos_list .to_right { margin-top: auto; margin-bottom: auto; } .convos_item_user:not(:last-child):after { content: ","; } .to_right { margin-left: auto; } .parti { margin-bottom: 8px; } .parti .rowitem { display: flex; } .parti_user:not(:last-child):after { content: ","; } .rowitem .topRow { display: flex; width: 100%; } .rowitem .userbit { display: flex; } .rowitem .topRow .nameAndTitle { display: flex; flex-direction: column; margin-left: 8px; } .nameAndTitle .real_username { font-size: 17px; line-height: 16px; } .userbit img { width: 40px; height: 40px; border-radius: 24px; } .controls { margin-left: auto; } .controls a { margin-right: 8px; } .content_column { margin-top: 5px; } .topic_reply_form { margin-top: 8px; padding: 12px; } .input_content { width: 100%; height: 100px; resize: vertical; } ================================================ FILE: themes/nox/public/fa-svg/LICENSE.txt ================================================ Font Awesome Free License ------------------------- Font Awesome Free is free, open source, and GPL friendly. You can use it for commercial projects, open source projects, or really almost whatever you want. Full Font Awesome Free license: https://fontawesome.com/license. # Icons: CC BY 4.0 License (https://creativecommons.org/licenses/by/4.0/) In the Font Awesome Free download, the CC BY 4.0 license applies to all icons packaged as SVG and JS file types. # Fonts: SIL OFL 1.1 License (https://scripts.sil.org/OFL) In the Font Awesome Free download, the SIL OLF license applies to all icons packaged as web and desktop font files. # Code: MIT License (https://opensource.org/licenses/MIT) In the Font Awesome Free download, the MIT license applies to all non-font and non-icon files. # Attribution Attribution is required by MIT, SIL OLF, and CC BY licenses. Downloaded Font Awesome Free files already contain embedded comments with sufficient attribution, so you shouldn't need to do anything additional when using these files normally. We've kept attribution comments terse, so we ask that you do not actively work to remove them from files, especially code. They're a great way for folks to learn about Font Awesome. # Brand Icons All brand icons are trademarks of their respective owners. The use of these trademarks does not indicate endorsement of the trademark holder by Font Awesome, nor vice versa. **Please do not use brand logos for any purpose except to represent the company, product, or service to which they refer.** ================================================ FILE: themes/nox/public/fa-svg/README.md ================================================ # Font Awesome 5.0.13 Thanks for downloading Font Awesome! We're so excited you're here. Our documentation is available online. Just head here: https://fontawesome.com ================================================ FILE: themes/nox/public/main.css ================================================ {{$darkest_bg := "#222222"}} {{$second_dark_bg := "#292929"}} {{$third_dark_bg := "#333333"}} * { box-sizing: border-box; } body { margin: 0px; padding: 0px; color: #AAAAAA; background-color: {{$darkest_bg}}; font-family: "Segoe UI"; } a { color: #eeeeee; text-decoration: none; } a:hover { color: #cccccc; } ::selection { color: #111111; background-color: #bbbbbb; } nav.nav { background: {{$darkest_bg}}; width: calc(100% - 200px); float: left; } ul { list-style-type: none; margin-top: 0px; margin-bottom: 0px; clear: both; } li { float: left; margin-right: 12px; } li a { padding-top: 35px; padding-bottom: 22px; font-size: 18px; display: inline-block; color: #aaaaaa; } #menu_overview { margin-right: 24px; } #menu_overview a { font-size: 22px; padding-bottom: 21px; color: rgb(221,221,221); padding-top: 31px; } .menu_left.menu_active a { border-bottom: 2px solid #777777; padding-bottom: 21px; color: #dddddd; } .menu_alerts .alert_bell, .menu_alerts .alert_counter, .menu_alerts:not(.selectedAlert) .alertList { display: none; } .alertList { display: flex; flex-direction: column; background-color: #444444; position: absolute; border: 1px solid #333333; top: 82px; border-top: none; right: 0px; padding-left: 16px; padding-right: 16px; } .alertItem { padding: 10px; padding-left: 8px; padding-right: 8px; } .alertItem:not(.withAvatar) { padding-top: 6px; padding-bottom: 6px; } .alertItem:not(.withAvatar) a { padding-top: 14px; padding-bottom: 18px; font-size: 17px; } .alertItem.withAvatar { background: none !important; height: 66px; padding-top: 4px; display: flex; padding: 16px; padding-left: 0px; padding-right: 0px; } .alertItem.withAvatar:not(:last-child) { border-bottom: 1px solid #555555; } .alertItem.withAvatar .bgsub { height: 36px; width: 36px; border-radius: 32px; } .alertItem.withAvatar .text { margin-left: 12px; padding-top: 5px; font-size: 16px; } .menu_hamburger > a:after { content: "{{lang "menu_more" . }}"; } .menu_hamburger, .more_menu, .menu_hide { display: none; } .more_menu { position: absolute; background-color: #444444; border: 1px solid #333333; flex-direction: column; list-style-type: none; padding: 16px; padding-top: 12px; padding-bottom: 12px; top: 70px; } .more_menu_selected { display: flex !important; } .more_menu li a { font-size: 17px; padding-top: 0px !important; padding-bottom: 0px !important; } .more_menu li a:not(:last-child) { padding-bottom: 8px !important; } .more_menu .menu_active a { border-bottom: none; } .right_of_nav { float: left; width: 200px; background-color: {{$darkest_bg}}; padding-top: 12px; padding-bottom: 12px; padding-right: 12px; } .user_box { display: flex; flex-direction: row; border-radius: 3px; background-color: {{$third_dark_bg}}; padding-top: 11px; padding-bottom: 11px; padding-left: 12px; } .user_box.has_alerts { padding-top: 10px; padding-bottom: 10px; border: 1px solid #444444; } a img:hover { filter: brightness(92%); } .user_box img { display: block; width: 36px; height: 36px; border-radius: 32px; margin-right: 8px; } .user_box .username { display: block; font-size: 16px; padding-top: 4px; line-height: 10px; } .user_box .alerts { font-size: 12px; line-height: 12px; } #container { clear: both; } #back { background: {{$third_dark_bg}}; padding: 24px; padding-top: 12px; clear: both; display: flex; } #main, #main .rowblock { width: 100%; } .alert { border-radius: 3px; background-color: #444444; padding: 12px; } .shrink_main .sidebar { width: 320px; } .widget_simple .rowitem { line-height: 18px; padding-top: 14px !important; padding-bottom: 14px !important; } .widget_simple.rowhead .rowitem { padding-bottom: 4px !important; } .the_form { border-radius: 3px; background-color: #444444; padding: 16px; } .rowblock:not(.topic_list):not(.rowhead):not(.opthead) .rowitem:not(.post_item), .topic_list .rowitem.rowmsg { border-radius: 3px; background-color: #444444; display: flex; padding: 12px; } .sidebar .rowblock:not(.topic_list):not(.rowhead):not(.opthead) .rowitem, .sidebar .search { margin-left: 12px; } .topics_moderate .can_mod { background-color: #4d4d4d; } .topics_moderate .can_mod:hover { background-color: rgb(78, 78, 98); } .widget_search:first-child { margin-top: 36px; } .widget_search input { width: 100%; height: 30px; margin-left: 0px; } .filter_list { margin-top: 5px; } .colstack_right .colstack_item:not(.colstack_head):not(.rowhead) .rowitem { border-radius: 3px; background-color: #444444; padding: 16px; } .filter_item a { color: #BBBBBB; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; } .colstack_right .colstack_item:not(.colstack_head):not(.rowhead) .rowitem:not(:last-child), .rowmsg { margin-bottom: 8px; } .colstack_right .colstack_head:not(:first-child) { margin-top: 16px; } h1, h2, h3, h4, h5 { -webkit-margin-before:0; -webkit-margin-after:0; margin-block-start:0; margin-block-end:0; margin-top:0; margin-bottom:0; font-weight:normal; white-space:nowrap; } /* new */ .filter_list { margin-top: 5px; background-color: #444444; margin-left: 12px; border-radius: 3px; } .filter_item { margin-left: 0px !important; border-radius: 0px !important; } .filter_item:hover { background-color: #505050 !important; } .filter_selected { background-color: #555555 !important; } .filter_selected a { color: #CCCCCC; } /* new end */ @keyframes fadein { from { opacity: 0; } to { opacity: 1; } } .modal_pane { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); background: #444444; border: 2px solid #333333; border-radius: 5px; padding: 12px; padding-top: 8px; z-index: 9999; animation: fadein 0.8s; } .pane_header { margin-bottom: 2px; } .pane_row { margin-top: 2px; cursor: pointer; } .pane_selected { font-weight: bold; } .pane_buttons { margin-top: 8px; } .mod_floater { position: absolute; right: 10px; bottom: 10px; background: #444444; border-radius: 5px; padding: 12px; padding-top: 8px; width: 200px; } .mod_floater_head span { margin-bottom: 6px; display: block; } .mod_floater_body { display: flex; } .mod_floater_options { width: 100%; margin-right: 10px; padding: 4px; margin-bottom: 0px; } #are_you_sure .rowblock .rowitem.passive { display: flex; flex-direction: column; } .rowhead, .opthead, .colstack_head { margin-left: 8px; margin-bottom: 8px; } .rowhead h1, .opthead h1, .colstack_head h1 { font-size: 21px; } .rowhead h1 + h2, .opthead h1 + h2, .colstack_right .colstack_head .rowitem h1 + h2 { margin-left: auto; } .sidebar .rowhead { margin-left: 18px; margin-top: 4px; margin-bottom: 8px; } .sidebar .rowhead h1 { font-size: 20px; } .sidebar .rowhead:not(:first-child) h1 { margin-top: 12px; font-size: 19px; } h2 { font-size: 18px; margin-top: 12px; margin-bottom: 8px; margin-left: 8px; } .rowhead h2, .colstack_head h2 { margin-top: 0px; margin-bottom: 0px; margin-left: 0px; } .topic_create_form { display: flex; } .quick_reply_form, .topic_reply_form, .topic_create_form { background-color: #444444; border-radius: 3px; } .quick_create_form { margin-bottom: 8px; padding: 16px; } .quick_create_form .little_row_avatar { border-radius: 36px; margin-left: 4px; margin-right: 20px; height: 48px; width: 48px; } .quick_create_form .main_form { width: 80%; } .quick_create_form .topic_meta { display: flex; } .quick_create_form input, .quick_create_form select { margin-left: 0px; margin-bottom: 0px; } .quick_create_form .topic_meta .topic_name_row { margin-bottom: 8px; width: 100%; font-size: 14px; } .quick_create_form .topic_meta .topic_name_row:not(:only-child) { margin-left: 6px; } .quick_create_form .topic_meta .topic_name_row:only-child input { margin-left: 0px; } .quick_create_form .topic_meta .topic_name_row input { width: 100%; } .quick_create_form .topic_content_row textarea { width: 100%; height: 60px; resize: vertical; } .quick_create_form .quick_button_row .formitem { display: flex; margin-top: 6px; } .quick_create_form .quick_button_row button, .quick_create_form .quick_button_row label { margin-right: 8px; } .quick_create_form #input_content { width: 100%; height: 100px; resize: vertical; } .uploadItem { display: inline-block; } .more_topic_block_initial { display: none; } .more_topic_block_active { display: block; } .hide_ajax_topic, .auto_hide, .show_on_edit:not(.edit_opened), .hide_on_edit.edit_opened, .show_on_block_edit:not(.edit_opened), .hide_on_block_edit.edit_opened, .link_select:not(.link_opened) { display: none !important; } .topic_list_title_block { display: flex; margin-left: 8px; } .topic_list_title { margin-left: 2px; } .topic_list_title_block .optbox { display: flex; font-size: 17px; margin-top: 3.5px; margin-right: 16px; margin-right: 18px; width: 100%; } .topic_list_title_block .pre_opt:before { content: "{{lang "topics_click_topics_to_select" . }}"; font-size: 17px; margin-right: 20px; } .topic_list_title_block .opt a { color: #afafaf; margin-left: 8px; white-space: nowrap; } .topic_list_title_block .create_topic_opt a:before { content: "{{lang "quick_topic.create_button" . }}"; } .topic_list_title_block .mod_opt a:before { content: "{{lang "topic_list.moderate" . }}"; } .topic_list_title_block .moderate_link.moderate_open:before { content: "{{lang "topic_list.cancel_mod" . }}"; } .filter_opt, .dummy_opt { margin-right: auto; } .filter_opt.opt a.filter_opt_label { font-size: 18px; margin-left: 5px; } .filter_opt_pointy { margin-left: -5px; } .link_select { background: #333333; background-color: #444444; position: absolute; border: 1px solid #333333; padding: 16px; padding-top: 10px; padding-bottom: 10px; margin-top: 2px; } .link_select .link_option a { margin-left: 0px; } .topic_row:not(:last-child) { margin-bottom: 8px; } .topic_row { border-radius: 3px; background-color: #444444; display: flex; } .topic_left, .topic_right, .topic_middle { padding: 16px; padding-bottom: 10px; padding-top: 16px; display: flex; width: 33%; } .topic_middle { line-height: 20px; } .topic_left { margin-right: auto; } .topic_sticky .topic_left { border-left: 3px solid rgb(215, 155, 0); border-radius: 3px; } .topic_closed .topic_left { border-left: 3px solid grey; border-radius: 3px; } .topic_closed { background-color: #4b4b4b; } .topic_row.topic_selected { background-color: rgb(68, 68, 88); } .new_item .topic_left { border-left: 3px solid rgb(215, 215, 215); border-radius: 3px; } .topic_left img, .topic_right img { border-radius: 24px; height: 38px; width: 38px; margin-right: 10px; } .topic_left img:hover, .topic_right img:hover { filter: brightness(95%); } .topic_inner_left { display: flex; flex-direction: column; width: 92%; } .topic_inner_left .rowtopic { white-space: nowrap; text-overflow: ellipsis; overflow: hidden; } .parent_forum_sep { margin-left: 6px; margin-right: 6px; } .topic_right_inside { display: flex; margin-left: auto; width: 180px; } .topic_right_inside .lastName, .topic_left .rowtopic { font-size: 15px !important; line-height: 22px; margin-top: -2px; } .topic_right_inside .lastName { font-size: 14px; } .topic_right_inside .lastReplyAt, .topic_left .starter { font-size: 14px; line-height: 14px; } .topic_right_inside span { display: flex; flex-direction: column; } .topic_inner_left br, .topic_right_inside br, .topic_inner_right, .topic_list:not(.topic_list_weekviews) .topic_middle .weekViewCount, .topic_list:not(.topic_list_mostviewed) .topic_middle .viewCount, .topic_list_weekviews .topic_middle .likeCount, .topic_list_mostviewed .topic_middle .likeCount { display: none; } .topic_middle_inside { display: flex; flex-direction: column; margin-left: auto; margin-right: auto; margin-top: -3px; width: 80px; } .topic_status_e { display: none; } /* TODO: Make a generic version of this so that we can have more blocks which are initially hidden but flip over to visible under certain conditions */ .more_topic_block_initial { display: none; } .more_topic_block_active { display: block; } input, select, button, .formbutton, .panel_right_button:not(.has_inner_button), textarea { border-radius: 3px; background: rgb(90,90,90); color: rgb(200,200,200); border: none; padding: 4px; } input:focus, select:focus, textarea:focus { outline: 1px solid rgb(120,120,120); } input:not(input[type=search]):not(input[type=submit]) { background-image: url(./fa-svg/pencil-alt.svg); background-size: 12px; background-repeat: no-repeat; background-position: right 10px bottom 9px; background-position-x: right 10px; } input { padding: 5px; padding-bottom: 3px; font-size: 16px; } input, select { margin-left: 3px; margin-bottom: 4px; } button, .formbutton, .panel_right_button:not(.has_inner_button) { background: rgb(110,110,210); color: rgb(250,250,250); font-family: "Segoe UI"; font-size: 15px; text-align: center; padding: 6px; } .formlabel { margin-bottom: 4px; } /*.formlabel + .formitem { margin-left: 4px; }*/ .formrow { margin-bottom: 6px; } .form_button_row { margin-top: 10px; } .formitem a { margin-bottom: 5px; display: block; } .login_mfa_token_row .formlabel { display: none; } .fall_opts { display: flex; } .dont_have_account, .forgot_password { margin-top: 12px; margin-bottom: -8px; } .forgot_password { margin-left: auto; } .pageset { display: flex; margin-top: 8px; } .pageitem { font-size: 17px; border-radius: 3px; background-color: #444444; padding: 7px; margin-right: 6px; } .pagefirst, .pagenext, .pageprev, .pagelast { padding-top: 2px; padding-bottom: 6px; } .pagefirst a, .pagenext a, .pageprev a, .pagelast a { font-size: 22px; } .pagecurrent { background-color: #505050; } #prevFloat, #nextFloat { display: none; } .forum_list .rowitem { margin-bottom: 8px; display: flex; } .forum_list .forum_left { margin-left: 8px; } .forum_list .forum_nodesc { font-style: italic; } .forum_list .forum_right { display: flex; margin-left: auto; margin-right: 8px; padding-top: 2px; width: 155px; } .forum_list .forum_right img { margin-right: 10px; margin-top: 2px; } .forum_list .forum_right span { line-height: 19px; overflow: hidden; text-overflow: ellipsis; } .forum_list .forum_right span a { white-space: nowrap; } .extra_little_row_avatar { border-radius: 24px; height: 36px; width: 36px; } .extra_little_row_avatar:hover { filter: brightness(92%); } .colstack, .topic_item { display: flex; } .topic_item .topic_name_forum_sep { font-size: 20px; line-height: 30px; margin-left: 7px; margin-right: 7px; } .topic_item .topic_forum { font-size: 19px; line-height: 30px; color: #cccccc; } .topic_view_count { font-size: 17px; margin-left: auto; margin-right: 20px; margin-top: 6px; } .topic_view_count:after { content: "{{lang "topic.view_count_suffix" . }}"; } .edithead { margin-left: 0px; margin-bottom: 10px; } .topic_name_input { width: 100%; margin-right: 10px; margin-bottom: 0px; margin-left: 0px; } sp.topic_item .submit_edit { /*margin-right: 16px;*/ } .zone_view_topic button, .zone_view_topic .formbutton { padding: 5px; padding-top: 4px; padding-bottom: 4px; } .postImage { width: 100%; max-width: 320px; } video { width: 100%; } blockquote { background-color: #555555; border-radius: 3px; padding: 8px; margin: 0px; margin-top: 8px; margin-bottom: 8px; } blockquote + br { display: none; } blockquote:only-child { margin-top: 0px; margin-bottom: 0px; } blockquote:first-child { margin-top: 0px; } .post_item { display: flex; margin-bottom: 12px; } .userinfo { margin-right: 12px; padding: 24px; padding-bottom: 16px; background-color: #444444; border-radius: 3px; width: 150px; } .userinfo, .user_meta { display: flex; flex-direction: column; } .avatar_item { background-position: 0px -10px; background-size: 78px; } .aitem, .avatar_item { height: 58px; width: 58px; border-radius: 36px; margin-left: auto; margin-right: auto; } .the_name { margin-left: auto; margin-right: auto; white-space: nowrap; display: block; font-size: 18px; margin-top: 8px; line-height: 16px; } .tag_block { display: flex; } .post_tag { white-space: nowrap; margin-left: auto; margin-right: auto; display: block; } .post_item .topic_content_input { resize: vertical; height: 150px; padding: 16px; } .post_item .content_container { border-radius: 3px; width: 100%; display: flex; flex-direction: column; color: #bbbbbb; } .action_item .content_container, .post_item .user_content, .post_item .button_container { background-color: #444444; border-radius: 3px; padding: 16px; } .user_content { word-break: break-word; } .user_content h2 { font-size: 20px; } .user_content h3 { font-size: 19px; } .user_content h2, .user_content h3 { margin-top: 3px; margin-bottom: 12px; margin-left: 0px; } .user_content h2 + br, .user_content h3 + br { display: none; } .user_content strong h2, .user_content strong h3 { font-weight: bold; } .user_content.in_edit { padding: 0px; background: none; } .user_content textarea { resize: vertical; height: 150px; width: 100% !important; padding: 16px; } red { color: red; } .hide_spoil { background-color: grey; color: grey; } .hide_spoil img { border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 50px; white-space: nowrap; width: 1px; background-color: grey; } .hide_spoil img { content: " "; } .attach_box { background-color: #5a5555; border-radius: 3px; padding: 16px; } .update_buttons { display: flex; background-color: #444444; border-radius: 4px; margin-top: 4px; /*8 without
*/ padding: 6px; } .user_content.in_edit a { margin-right: 8px; } .post_item .button_container { display: flex; margin-top: 8px; margin-bottom: auto; padding: 14px; } .post_item .action_button { margin-right: 5px; font-size: 15px; color: #dddddd; white-space: nowrap; } .post_item .action_button_left, .post_item .action_button_right { display: flex; } .post_item .action_button_right { margin-left: auto; } .post_item .controls:not(.has_likes) .like_count, .action_item .userinfo, .action_item .action_icon { display: none; } .action_item .content_container { padding-top: 12px; padding-bottom: 12px; } .action_item .content_container span { margin-left: auto; margin-right: auto; } input[type=checkbox] { display: none; } input[type=checkbox] + label { display: inline-flex; width: 18px; height: 18px; margin-bottom: -2px; margin-right: 8px; background-color: rgb(90,90,90); padding-top: 1px; border-radius: 2px; } input[type=checkbox]:checked + label .sel, input[type=checkbox]:not(:checked):hover + label .sel { width: 8px; height: 8px; margin: auto; border-radius: 2px; } input[type=checkbox]:checked + label .sel { background: rgb(140,140,140); } input[type=checkbox]:not(:checked):hover + label .sel { background: rgb(120,120,120); } .poll_option { display: flex; margin-bottom: 10px; } .poll_option_text { line-height: 14px; } .poll_buttons { padding-top: 4px; } .poll_buttons button { margin-right: 8px; } .poll_results { margin-left: 12px; } .pollinput { margin-bottom: 5px; } .pollinput:last-child { margin-bottom: 12px; } .ip_item { display: none; } .add_like:before, .remove_like:before { content:"{{lang "topic.plus_one" . }}"; } .remove_like:before { content:"{{lang "topic.minus_one" . }}"; } .button_container .open_edit:after, .edit_item:after { content:"{{lang "topic.edit_button_text" . }}"; } .ip_item_button:after { content:"{{lang "topic.ip_button_text" . }}"; }{{$p := .}} {{range (toArr "quote" "delete" "lock" "unlock" "pin" "unpin" "report")}} .{{.}}_item:after { content:"{{lang (concat "topic." . "_button_text") ($p) }}"; }{{end}} .like_count:after { content:"{{lang "topic.like_count_suffix" . }}"; } .attach_item { display: flex; background-color: #444444; border-radius: 4px; margin-top: 8px; padding: 6px; text-overflow: ellipsis; overflow: hidden; } .attach_item_selected { background-color: #446644 } .attach_item img { margin-right: 8px; border-radius: 4px; } .attach_edit_bay button { margin-top: 8px; margin-left: 8px; } /* New */ .attach_item { padding: 8px; width: 100%; } .attach_item_item span { margin-bottom: 4px; margin-right: auto; overflow: hidden; text-overflow: ellipsis; width: 350px; } .attach_image_holder span { width: 300px; } .attach_item button { margin-top: -1px; } .post_item:not(.has_attachs):not(.top_post) .attach_item_buttons, .post_item:not(.has_attachs) .attach_item_delete, .has_attachs .update_buttons .add_file_button { display: none; } .zone_view_topic .pageset { margin-bottom: 14px; } .topic_reply_container { display: flex; } .rowlist.bgavatars:not(.not_grid), .micro_grid { display: grid; /*grid-gap: 16px; grid-row-gap: 8px;*/ grid-gap: 24px; grid-row-gap: 16px; grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } .rowlist.bgavatars.micro_grid, .micro_grid { grid-gap: 16px; grid-row-gap: 4px; grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); } .rowlist.bgavatars .rowitem, .micro_grid .rowitem { display: flex; flex-direction: column; /*width: 180px;*/ background-image: none !important; margin-bottom: 10px; padding: 16px; } .rowlist.not_grid .rowitem { flex-direction: row; } .rowlist.bgavatars .bgsub, .rowlist.bgavatars .rowTitle, .rowlist.bgavatars .rowAvatar { margin-left: auto; margin-right: auto; } .rowlist.bgavatars .bgsub { border-radius: 32px; height: 64px; width: 64px; } .rowlist.bgavatars .rowTitle { font-size: 18px; margin-top: 4px; } .rowlist.bgavatars .rowAvatar { margin-bottom: -4px; } .rowlist.bgavatars.not_grid .bgsub { height: 28px; width: 28px; margin-left: 4px; margin-right: 10px; } .rowlist.bgavatars.not_grid .rowTitle { font-size: 17px; margin-left: 0px; margin-top: 0px; } .loglist .to_left small { margin-left: 2px; font-size: 12px; } .ip_search_block { margin-bottom: 12px; } .ip_search_input { width: 100%; margin-right: 8px; } .footer .widget, .elapsed { padding: 12px; border-bottom: 1px solid #555555; } .elapsed { padding: 6px; background: rgb(82,82,82); border-radius: 3px; font-size: 13.5px; color: rgb(200,200,200); margin-top: 1px; margin-bottom: 4px; padding-bottom: 2px; padding-top: 3px; margin-right: 3px; } #poweredByHolder { display: flex; padding-top: 12px; padding-left: 16px; padding-right: 16px; padding-bottom: 8px; } #poweredBy { margin-right: auto; } .footer .widget, #poweredByHolder { background-color: #444444; } .level_complete, .level_future, .level_inprogress, .progressWrap { display: flex; } .level_inprogress { position: relative; } .level_complete { background-color: rgb(68, 93, 68) !important; width: 100%; } .level_future { background-color: rgb(88, 68, 68) !important; width: 100%; } .progressWrap { margin-left: auto; } .levelBit { color: #dadada; } /* CSS behaves in stupid ways, so we need to be very specific about this */ .rowblock:not(.topic_list):not(.rowhead):not(.opthead) .rowitem.level_inprogress:not(.post_item), .coldyn_item .rowitem.level_inprogress { padding: 0px !important; } .level_inprogress > div { display: flex; padding-top: 12px; padding-bottom: 12px; padding-left: 12px; border-radius: 3px; /*width: 100%;*/ } .level_inprogress:not(.level_zero) .levelBit { background-color: rgb(68, 93, 68) !important; } .level_inprogress .levelBit { display: inline; position: absolute; z-index: 1; } .level_inprogress .levelBit a { white-space: nowrap; } .level_inprogress .progressWrap { /*width: 100%;*/ padding-left: 0px; padding-right: 12px; /*background-color: rgb(68, 68, 68) !important;*/ z-index: 2; } .level_inprogress .progressWrap div { margin-left: auto; white-space: nowrap; } @media(max-width: 600px) { .rowhead h1, .opthead h1, .colstack_head h1 { font-size: 19px; } .topic_list_title_block .opt { margin-top: -1px; } .topic_list_title_block .opt a { font-size: 16px; } .topic_list .topic_middle { display: none; } .topic_left, .topic_right, .topic_middle { width: 50%; } .topic_right_inside .lastName, .topic_left .rowtopic { margin-top: -4px; } .topic_left img, .topic_right img { height: 32px; width: 32px; } .topic_list .topic_right_inside .lastReplyAt, .topic_list .topic_left .starter { white-space: nowrap; } .userinfo { padding: 18px; width: 140px; } .avatar_item { height: 48px; width: 48px; background-size: 68px; } .the_name { font-size: 17px; } } @media(max-width: 500px) { .sidebar, .topic_view_count { display: none; } .post_item .button_container { display: block; margin-top: 8px; background: transparent; padding: 0px; } .post_item .action_button_left { display: block; background-color: #444444; border-radius: 3px; padding: 10px; } .post_item .action_button_right { background-color: #444444; border-radius: 3px; padding: 10px; padding-left: 14px; /*padding-right: 12px;*/ margin-top: 8px; } .post_item .controls:not(.has_likes) .like_count { display: inline; } .post_item .created_at { margin-left: auto; } .post_item, .topic_reply_container { flex-direction: column; } .userinfo { margin-right: 0px; width: auto; flex-direction: row; margin-bottom: 8px; padding: 18px; padding-bottom: 14px; } .avatar_item { height: 46px; width: 46px; margin-left: 0px; margin-right: 0px; } .user_meta { margin-left: 10px; margin-top: -4px; } } @media(max-width: 460px) { ul { background: #3f3f3f; } .topic_list_title, .filter_opt_sep { display: none; } .topic_list_title_block .create_topic_opt a:before { content: "{{lang "quick_topic.create_button_short" . }}"; } .topic_list_title_block .mod_opt a:before { content: "{{lang "topic_list.moderate_short" . }}"; } .topic_inner_left .parent_forum, .parent_forum_sep { display: none; } } @media(max-width: 601px) { ul { padding-left: 14px; } li a { padding-bottom: 6px; font-size: 15px; color: #bfbfbf; } #menu_overview { margin-right: 10px; } #menu_overview a { font-size: 17px; padding-bottom: 7px; color: rgb(226,226,226); padding-top: 12px; } .menu_left.menu_active a { color: #cfcfcf; } } @media (max-width: 750px) and (min-width: 600px) { ul { padding-left: 16px; } #menu_overview { margin-right: 12px; } #menu_overview a { font-size: 19px; padding-bottom: 5px; color: rgb(231,231,231); padding-top: 11px; } li a { padding-bottom: 13px; font-size: 16px; color: #cfcfcf; } .menu_left.menu_active a { color: #dddddd; } } @media (max-width: 750px) { nav.nav { background: #2a2a2a; width: 100%; } ul { display: flex; padding-right: 0px; } li { float: left; margin-right: 6px; } li a { padding-top: 14px; display: inline-block; } .menu_left.menu_active a { padding-bottom: 15px; border: none; } .more_menu { top: 50px; } .right_of_nav { width: auto; padding: 0px; } .user_box, .elapsed { display: none; } #back { flex-direction: column; } .topic_item .topic_name_forum_sep { font-size: 17px; line-height: 28px; margin-left: 5px; margin-right: 5px; } .topic_item .topic_forum { font-size: 17px; line-height: 28px; } } @media(min-width: 751px) { .menu_profile { display: none; } .shrink_main #main { max-width: calc(100% - 180px); } } {{/**@media(max-width: 850px) { // }**/}} @media(min-width: 1010px) { #container { background-color: {{$second_dark_bg}}; } #back, .footer { width: 1000px; margin-left: auto; margin-right: auto; } .footBlock, .footer { display: flex; } .footer { flex-direction: column; } .userinfo { width: 180px; padding-bottom: 18px; } .userinfo .avatar_item { height: 64px; width: 64px; background-size: 88px; } .userinfo .the_name { font-size: 19px; } .userinfo .post_tag { font-size: 17px; } } @media(min-width: 1330px) { nav.nav { width: calc(85% - 200px) } ul { margin-left: 205px; } .right_of_nav { width: calc(15% + 200px); } .user_box { width: 200px; } } ================================================ FILE: themes/nox/public/misc.js ================================================ "use strict"; function noxMenuBind() { $(".more_menu").remove(); $("#main_menu li:not(.menu_hamburger").removeClass("menu_hide"); let mWidth = $("#main_menu").width(); let iWidth = 0; let lastElem = null; $("#main_menu > li:not(.menu_hamburger)").each(function(){ iWidth += $(this).outerWidth(); if(iWidth > (mWidth - 100) && (mWidth - 100) > 0) { this.classList.add("menu_hide"); if(lastElem!==null) lastElem.classList.add("menu_hide"); } lastElem = this; }); if(iWidth > (mWidth - 100) && (mWidth - 100) > 0) $(".menu_hamburger").show(); else $(".menu_hamburger").hide(); let div = document.createElement('div'); div.className = "more_menu"; $("#main_menu > li:not(.menu_hamburger):not(#menu_overview)").each(function(){ if(!this.classList.contains("menu_hide")) return; let cop = this.cloneNode(true); cop.classList.remove("menu_hide"); div.appendChild(cop); }); document.getElementsByClassName("menu_hamburger")[0].appendChild(div); } (() => { if(window.location.pathname.startsWith("/panel/")) { addInitHook("pre_global", () => noAlerts = true); } function moveAlerts() { // Move the alerts above the first header let cSel = $(".colstack_right .colstack_head:first"); let cSelAlt = $(".colstack_right .colstack_item:first"); let cSelAltAlt = $(".colstack_right .coldyn_block:first"); if(cSel.length > 0) $('.alert').insertBefore(cSel); else if (cSelAlt.length > 0) $('.alert').insertBefore(cSelAlt); else if (cSelAltAlt.length > 0) $('.alert').insertBefore(cSelAltAlt); else $('.alert').insertAfter(".rowhead:first"); } addInitHook("after_update_alert_list", count => { log("misc.js"); log("count",count); if(count==0) { $(".alerts").html(phraseBox["alerts"]["alerts.no_alerts_short"]); $(".user_box").removeClass("has_alerts"); } else { // TODO: Localise this $(".alerts").html(count+" new alerts"); $(".user_box").addClass("has_alerts"); } }); addHook("open_edit", () => $('.topic_block').addClass("edithead")); addHook("close_edit", () => $('.topic_block').removeClass("edithead")); addInitHook("end_init", () => { $(".alerts").click(ev => { ev.stopPropagation(); let alerts = $(".menu_alerts")[0]; if($(alerts).hasClass("selectedAlert")) return; if(!conn) loadAlerts(alerts); alerts.className += " selectedAlert"; document.getElementById("back").className += " alertActive" }); $(window).resize(() => noxMenuBind()); noxMenuBind(); moveAlerts(); $(".menu_hamburger").click(function() { event.stopPropagation(); let mm = document.getElementsByClassName("more_menu")[0]; mm.classList.add("more_menu_selected"); let calc = $(this).offset().left - (mm.offsetWidth / 4); mm.style.left = calc+"px"; }); $(document).click(() => $(".more_menu").removeClass("more_menu_selected")); }); addInitHook("after_notice", moveAlerts); })(); ================================================ FILE: themes/nox/public/panel.css ================================================ #back { padding: 0px; } #back, .footer .widget, #poweredByHolder { border: none; } .left_of_nav, .nav, .right_of_nav, .sidebar { display: none; } .footer .widget, #poweredByHolder { background-color: #393939; } .submenu a:before { content: "-"; margin-right: 8px; } {{template "acc_panel_common.css" }} .colstack_left .colstack_head { font-size: 19px; /*padding-top: 10px; padding-bottom: 10px;*/ padding-top: 12px; padding-bottom: 12px; } .menu_stats { margin-left: 4px; } .above_right { background-color: rgb(62, 62, 62); margin-top: -12px; margin-left: -24px; margin-right: -24px; display: flex; } .above_right .left_bit { padding-left: 20px; margin-top: 16px; font-size: 18px; } .above_right .left_bit a { color: #bbbbbb; } .above_right .right_bit { margin-left: auto; display: flex; background-color: rgb(72, 72, 72); padding-top: 12px; padding-bottom: 12px; padding-right: 22px; padding-left: 20px; } .above_right img { border-radius: 24px; } .above_right span { margin-left: 12px; margin-top: 5px; color: rgb(180, 180, 180); } .colstack_right { background-color: #333333; width: 90%; padding-top: 12px; padding-right: 24px; padding-bottom: 24px; padding-left: 24px; } .colstack_right .colstack_head { margin-bottom: 5px; } .colstack_right .colstack_head + .colstack_head:not(:first-child) { margin-top: 5px; } .colstack_right .colstack_head h1 { font-size: 21px; } .colstack_right .colstack_head h1 + h2.hguide { margin-left: auto; font-size: 17px; } .colstack_right .colstack_item.the_form, .colstack_right .colstack_item:not(.colstack_head):not(.rowhead) .rowitem { background-color: #444444; } .colstack_right .colstack_head.colstack_sub_head:not(:first-child) { margin-top: 12px; } .colstack_head + .colstack_head.colstack_sub_head:not(:first-child) { margin-top: 2px; } .alert { margin-top: 18px; } .rowitem, .formitem.avataritem { display: flex; } .formitem.avataritem { flex-direction: column; } .avataritem .avatarbuttons { margin-top: 7px; margin-bottom: 3px; } .colstack_grid { display: grid; grid-gap: 8px; grid-template-columns: repeat(3, 1fr); } .rowlist.bgavatars, .micro_grid { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); } .grid_item { border-radius: 3px; color: rgb(190,190,190); background-color: rgb(68,68,68); padding: 12px; } .grid_item a { color: rgb(195,195,195); } .stat_green { background-color: rgb(68,88,68); } .stat_orange { background-color: rgb(88,78,68); } .stat_red { background-color: rgb(88,68,68); } .grid2 { margin-top: 12px; } .panel_buttons, .panel_floater { margin-left: auto; } .colstack_right input, .colstack_right select, .colstack_right textarea, .formitem img { padding: 4px; padding-bottom: 3px; padding-left: 6px; padding-right: 6px; } #panel_users .rowitem { padding-top: 20px; padding-left: 4px; padding-right: 4px; padding-bottom: 18px; } button, .formbutton, .panel_right_button:not(.has_inner_button), #panel_users .profile_url { background: rgb(100,100,200); } #panel_users .panel_tag:not(.panel_right_button) { background: rgb(50,150,50); } .panel_right_button:not(.has_inner_button), .panel_right_button button, #panel_users .panel_tag:not(.panel_right_button), #panel_users .profile_url { margin-left: 2px; padding: 5px; padding-left: 6px; padding-right: 6px; } #panel_users .panel_tag:not(.panel_right_button), #panel_users .profile_url { color: rgb(250,250,250); font-size: 15px; text-align: center; border-radius: 3px; } .edit_button:after { content: "{{lang "panel_edit_button_text" . }}"; } .delete_button:after { content: "{{lang "panel_delete_button_text" . }}"; } /*#themeSelector select { background: rgb(90,90,90); color: rgb(200,200,200); }*/ select + .timeRangeSelector { margin-left: 8px; } .colstack_graph_holder { background-color: #444444; border-radius: 3px; padding: 16px; padding-bottom: 0px; padding-left: 0px; padding-right: 0px; margin-bottom: 10px; } .colstack_graph_holder.scrolly { overflow-x: scroll; width: 800px; } .colstack_graph_holder.scrolly .ct_chart { width: 1000px; } .colstack_graph_holder .ct-label { color: rgb(195,195,195); font-size: 13px; white-space: nowrap; } .colstack_graph_holder .ct-horizontal { margin-top: 3px; } .colstack_graph_holder .ct-grid { stroke: rgb(125,125,125); } .ct-legend { margin-left: 0px; } .ct-series-e .ct-bar, .ct-series-e .ct-line, .ct-series-e .ct-point, .ct-series-e .ct-slice-donut { stroke: #c73eaf !important; } .ct-series-e.ct-point { stroke: #c73eaf !important; } .ct-series-e.ct-point:hover { stroke: #c73eaf !important; } .ct-legend .ct-series-4:before { background-color: #c73eaf !important; border-color: /*#ed4cd0*/#c73eaf !important; } /*.ct-series-f .ct-bar, .ct-series-f .ct-line, .ct-series-f .ct-point, .ct-series-f .ct-slice-donut { stroke: darkred !important; } .ct-series-f.ct-point { stroke: darkred !important; } .ct-series-f.ct-point:hover { stroke: darkred !important; } .ct-legend .ct-series-5:before { background-color: darkred !important; border-color: darkred !important; }*/ .ct-legend .ct-series-7:before { background-color: #6b0392 !important; border-color: #6b0392 !important; } #panel_setting .formlabel { display: none; }/* #panel_setting textarea { width: 100%; height: 80px; } .micro_grid .to_right, .micro_grid .panel_buttons { margin-left: 0px; } #panel_settings .panel_upshift { margin-bottom: 12px; } #panel_settings .to_right { white-space: nowrap; margin-top: auto; padding-top: 10px; background-color: #555555; border-radius: 5px; padding-left: 5px; padding: 12px; overflow: hidden; text-overflow: ellipsis; } #panel_settings.rowlist.bgavatars.micro_grid, .micro_grid { grid-gap: 24px; grid-row-gap: 16px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } #panel_word_filters { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); } #panel_word_filters .filters_find { margin-bottom: 1px; } #panel_word_filters .itemSeparator:before { content: "{{lang "panel_word_filters_to" . }}"; font-size: 17px; margin-bottom: 1px; } #panel_word_filters .panel_buttons { margin-top: 14px; } #panel_users .rowitem .to_right { order: 0; margin-right: auto; } #panel_users .rowitem .profile_url { order: 1; } #panel_users .rowitem .panel_floater { order: 2; margin-top: 8px; margin-right: auto; } .panel_group_promotions .formitem { display: flex; } .perm_preset_no_access:before { content: "{{lang "panel_perms_no_access" . }}"; /*color: hsl(0,100%,20%);*/ } /*.perm_preset_read_only:before, .perm_preset_can_post:before { color: hsl(120,100%,20%); }*/ .perm_preset_read_only:before { content: "{{lang "panel_perms_read_only" . }}"; } .perm_preset_can_post:before { content: "{{lang "panel_perms_can_post" . }}"; } .perm_preset_can_moderate:before { content: "{{lang "panel_perms_can_moderate" . }}"; /*color: hsl(240,100%,20%);*/ } .perm_preset_quasi_mod:before { content: "{{lang "panel_perms_quasi_mod" . }}"; } .perm_preset_custom:before { content: "{{lang "panel_perms_custom" . }}"; /*color: hsl(0,0%,20%);*/ } .perm_preset_default:before { content: "{{lang "panel_perms_default" . }}"; } .panel_submitrow { margin-top: 8px; } .colstack_right .colstack_item:not(.colstack_head):not(.rowhead).panel_submitrow .rowitem { padding-bottom: 14px; } .panel_submitrow .rowitem button:first-child { margin-left: auto; } .panel_submitrow .rowitem button:last-child { margin-right: auto; } /*.has_inner_button button { margin-right: 8px; }*/ #forum_quick_perms .formitem, #forum_quick_perms .panel_floater { display: flex; } #forum_quick_perms .edit_fields { margin-left: 4px; } span.grip { content: '....'; width: 20px; display: inline-block; overflow: hidden; line-height: 5px; padding: 3px 4px; cursor: move; vertical-align: middle; margin-top: -16px; margin-right: 12px; font-size: 12px; font-family: sans-serif; letter-spacing: -3px; color: #888888; text-shadow: 1px 0 1px black; margin-left: -12px; height: 100%; font-size: 40px; margin-bottom: -4px; line-height: 8px; } span.grip::after { content: '... ... ... ... ... ... ...'; } .forum_no_desc span.grip, .panel_menu_item span.grip { height: 40px; } .panel_plugin_meta { display: flex; flex-direction: column; } .panel_plugin_meta br { display: none; } .panel_plugin_meta small { margin-left: 0px !important; margin-top: 1px; } /* TODO: Switch out this hack for vertically aligning the buttons */ /* margin-top: 10px; */ #panel_plugins .to_right { display: flex; } #panel_plugins .to_right .panel_right_button { margin-top: auto; margin-bottom: auto; } .widget_normal { display: flex; width: 100%; } .bg_red.in_edit.widget_item { background-color: #444444 !important; } .widget_item .form_button_row .rowitem { display: flex; } .widget_edit .form_button_row .formitem a { display: inline; } .colstack_right .colstack_item.the_form, .colstack_right .colstack_item:not(.colstack_head):not(.rowhead) .rowitem.widget_new { padding-top: 12px; padding-bottom: 12px; } #widgetTmpl, .widget_disabled { display: none; } .bg_red .widget_disabled { display: inline; } .wtypes .formrow { display: none; } .wtype_about .w_about, .wtype_simple .w_simple, .wtype_wol .w_wol, .wtype_default .w_default { display: block; } .wtext, .rwtext { width: 100%; height: 80px; } #panel_reglogs .panel_compactrow { flex-direction: column; } .logdetail { display: flex; width: 100%; margin-top: 3px; } #panel_reglogs .logdetail small, #panel_reglogs .logdetails span { font-size: 14px; } #panel_debug .grid_stat:not(.grid_stat_head) { margin-bottom: 5px; } @media (max-width: 1000px) { #panel_settings.rowlist.bgavatars.micro_grid, .micro_grid { grid-gap: 12px; grid-row-gap: 4px; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } } ================================================ FILE: themes/nox/public/profile.css ================================================ #main { max-width: none !important; } #profile_left_lane { margin-right: 24px; } .avatarRow { display: flex; width: 100%; } .avatar, .nameRow span, .passiveBlock .passive { margin-left: auto; margin-right: auto; } .avatar { width: 64px; height: 64px; border-radius: 32px; } .avatarRow a { margin-left: auto; margin-right: auto; } .avatarRow .avatar { display: block; } .nameRow { display: flex; flex-direction: column; } .profileName { font-size: 21px; } .topBlock, .levelBlock, .passiveBlock { background-color: #444444; border-radius: 3px; width: 180px; padding: 16px; } .levelBlock, .passiveBlock { margin-top: 12px; padding: 12px; } .levelBlock { padding: 0px; } .colstack_right .colstack_head:not(:first-child) { margin-top: 0px; } #profile_right_lane { width: 100%; } #profile_comments .rowitem .topRow { display: flex; width: 100%; } #profile_comments .rowitem .userbit { display: flex; } #profile_comments .rowitem .topRow .nameAndTitle { display: flex; flex-direction: column; margin-left: 8px; } .nameAndTitle .real_username { font-size: 17px; line-height: 16px; } .userbit > a { height: 40px; } .userbit img { width: 40px; height: 40px; border-radius: 24px; } .controls { margin-left: auto; } .controls a { margin-right: 8px; } .content_column { margin-top: 5px; } .topic_reply_form { margin-top: 8px; padding: 12px; } .input_content { width: 100%; height: 100px; resize: vertical; } .footer .widget, .sidebar { display: none; } @media(max-width: 500px) { .colstack { display: block; } #profile_left_lane { margin-right: 0px; margin-bottom: 12px; } .topBlock, .levelBlock, .passiveBlock { width: auto; } } ================================================ FILE: themes/nox/theme.json ================================================ { "Name": "nox", "FriendlyName": "Nox", "Version": "0.0.1", "Creator": "Azareal", "URL": "github.com/Azareal/Gosora", "Tag": "WIP", "Docks":["topMenu","rightSidebar","footer"], "GridLists": true, "MapTmplToDock": { "rightOfNav": { "File": "./templates/userDock.html" } }, "Templates": [ { "Name": "topic", "Source": "topic_alt" }, { "Name": "topic_mini", "Source": "topic_alt_mini" } ], "Resources": [ { "Name":"trumbowyg/trumbowyg.min.js", "Location":"global", "Loggedin":true }, { "Name":"trumbowyg/ui/trumbowyg.custom.css", "Location":"global", "Loggedin":true }, { "Name":"nox/misc.js", "Location":"global", "Async":true } ] } ================================================ FILE: themes/shadow/DEVELOPERS.md ================================================ # Theme Notes /public/post-avatar-bg.jpg is a solid rgb(71,71,71) ================================================ FILE: themes/shadow/overrides/login.html ================================================ {{template "header.html" . }}

{{lang "login_head"}}

{{template "footer.html" . }} ================================================ FILE: themes/shadow/public/account.css ================================================ #account_dashboard .colstack_right .coldyn_block { display: flex; } #dash_left { padding: 18px; padding-right: 0px; padding-top: 11px; padding-left: 0px; width: 260px; position: relative; } #dash_left .rowitem { margin-top: 0px; } #dash_username { display: flex; } #dash_username button { margin-left: 6px; } #dash_left .rowitem img { width: 100%; margin-top: 8px; margin-bottom: 4px; margin-left: 0px; margin-right: 12px; } #dash_avatar_buttons { display: flex; } #dash_avatar_buttons button { margin-left: 8px; } #dash_right { width: 100%; padding: 16px; padding-top: 3px; padding-left: 8px; padding-right: 0px; } .account_soon, .dash_security { font-size: 13px; color: rgba(255, 80, 80, 1); } .rowmenu .account_soon, .rowmenu .dash_security { font-size: 11px; } .validated_email { color: rgb(0, 170, 0); } .invalid_email { color: crimson; } ================================================ FILE: themes/shadow/public/convo.css ================================================ .convos_item_user:not(:last-child):after { content: ","; } .parti { margin-bottom: 8px; } .parti .rowitem { display: flex; } .parti_user:not(:last-child):after { content: ","; } .convo_row_box .rowitem { background-repeat: no-repeat, repeat-y; background-size: 128px; padding-left: 136px; } ================================================ FILE: themes/shadow/public/main.css ================================================ /* Patch for Edge, until they fix emojis in arial x.x */ @supports (-ms-ime-align:auto) { .user_content { font-family: Segoe UI Emoji, arial; } } :root { --main-block-color: rgb(61,61,61); --main-text-color: white; --dim-text-color: rgb(205,205,205); --main-background-color: #222222; --inner-background-color: #333333; --input-background-color: #444444; --input-border-color: #555555; --input-text-color: #999999; --bright-input-background-color: #555555; --bright-input-border-color: #666666; --input-text-color: #a3a3a3; } body { font-family: arial; color: var(--main-text-color); background-color: var(--main-background-color); margin: 0; } *::selection { background-color: hsl(0,0%,75%); color: hsl(0,0%,20%); font-weight: 100; } #back { margin-left: auto; margin-right: auto; width: 70%; background-color: var(--inner-background-color); position: relative; top: -2px; } #main { padding-bottom: 5px; } #main_menu { list-style-type: none; background-color: var(--main-block-color); border-bottom: 1px solid var(--main-background-color); padding-left: 15%; padding-right: 15%; margin: 0; height: 41px; } .menu_left, .menu_right li { float: left; height: 29.5px; padding-top: 12px; margin: 0; } .menu_left { margin-right: 10px; } .menu_right { float: right; } #main_menu #menu_overview { margin-right: 13px; margin-left: 10px; font-size: 16px; } #main_menu .menu_left:not(#menu_overview) { font-size: 15px; padding-top: 13px; } .alert_bell { float: right; } .menu_alerts { float: right; padding-top: 14px; } .alert_counter { background-color: rgb(200,0,0); border-radius: 2px; font-size: 11px; padding: 3px; float: right; position: relative; top: -1px; } .alert_aftercounter { float: right; margin-right: 4px; font-size: 14px; } .alert_aftercounter:before { content: "{{lang "menu_alerts" . }}"; } .menu_alerts .alertList, .hide_on_big, .show_on_mobile { display: none; } .auto_hide { display: none !important; } .selectedAlert .alertList { display: block; position: absolute; top: 44px; float: left; width: 200px; z-index: 50; right: 15%; font-size: 13px; background-color: var(--inner-background-color); } .alertItem { margin-bottom: 2px; } .alertItem.withAvatar { height: 40px; background-size: 48px; background-repeat: no-repeat; background-color: var(--main-block-color); padding-left: 56px; padding-top: 8px; } a { text-decoration: none; color: var(--main-text-color); } .alertbox { display: flex; } .alert { padding-bottom: 12px; background-color: var(--main-block-color); border-left: 4px solid hsl(21, 100%, 50%); padding: 12px; display: block; margin-top: 8px; margin-bottom: -3px; margin-left: 8px; margin-right: 8px; width: 100%; } .rowblock { margin-left: 8px; margin-right: 8px; } .opthead, .rowhead, .colstack_head { padding-bottom: 0px; padding-top: 3px !important; white-space: nowrap; } .rowblock:not(.opthead):not(.colstack_head):not(.rowhead) .rowitem { font-size: 15px; /*16px*/ } .rowblock:last-child, .colstack_item:last-child { padding-bottom: 10px; } .rowitem, .formitem { padding-bottom: 12px; background-color: var(--main-block-color); margin-top: 8px; padding: 12px; } .rowitem h1, .rowitem h2 { font-size: 16px; display: inline; } h1, h2, h3, h4, h5 { -webkit-margin-before:0; -webkit-margin-after:0; margin-block-start:0; margin-block-end:0; margin-top:0; margin-bottom:0; font-weight: normal; } .rowsmall { font-size: 12px; } .colstack { display: flex; } .colstack_left, .colstack_right { margin-left: 8px; } .colstack_left { float: left; width: 30%; } .colstack_right { float: left; width: calc(70% - 24px); } .colstack_left:empty, .colstack_right:empty, .show_on_edit:not(.edit_opened), .hide_on_edit.edit_opened, .show_on_block_edit:not(.edit_opened), .hide_on_block_edit.edit_opened, .link_select:not(.link_opened) { display: none; } .colline { font-size: 14px; background-color: var(--main-block-color); margin-top: 5px; padding: 10px; } /* Align to right in a flex head */ .to_left { float: left; } .to_right { float: right; margin-left: auto; } /* Topic View */ /* TODO: How should we handle the sticky headers? */ .topic_sticky_head {} /* TODO: Add the avatars to the forum list */ .forum_list .forum_nodesc { font-style: italic; } .extra_little_row_avatar { display: none; } .shift_left { float: left; } .shift_right { float: right; } .action_item .action_icon { font-size: 18px; padding-right: 5px; } /* TODO: Rewrite the closed topic header so that it looks more consistent with the rest of the theme */ .topic_closed_head .topic_status_closed { margin-bottom: -10px; font-size: 19px; } .post_item { background-size: 128px; padding-left: calc(128px + 12px); } .user_content { word-break: break-word; } .user_content h2 { font-size: 18px; } .user_content h2, .user_content h3 { margin-bottom: 12px; display: block; } .user_content h4 { margin-bottom: 8px; display: block; } .user_content strong h2, .user_content strong h3, .user_content strong h4 { font-weight: bold; } red { color: red; } .update_buttons .add_file_button { display: none; } .controls { width: 100%; display: inline-block; margin-top: 20px; } .staff_post { border: 1px solid rgb(101, 71, 101) } .user_tag { float: right; color: var(--dim-text-color); } .real_username { float: left; margin-right: 7px; } .mod_button { margin-right: 5px; display: block; float: left; } .mod_button button { border: none; background: none; color: var(--main-text-color); font-size: 12px; padding: 0; } .like_label:before { content: "{{lang "topic.plus_one" . }}"; }{{$out := .}} {{range (toArr "quote" "edit" "delete" "pin" "lock" "unlock" "unpin" "ip" "flag")}} .{{.}}_label:before { content: "{{lang (concat "topic." . "_button_text") ($out) }}"; }{{end}} .like_count_label, .like_count { display: none; } .like_count_label:before { content: "{{lang "topics_likes_suffix" . }}"; } .has_likes .like_count_label, .has_likes .like_count { font-size: 12px; display: block; float: left; line-height: 19px; } .has_likes .like_count { margin-right: 2px; } .like_count:before { content: "{{lang "pipe" . }}"; margin-right: 5px; } .level_label, .level { color: var(--dim-text-color); float: right; } .level_label:before { content: "{{lang "topic.level_tooltip" . }}"; } .level { margin-left: 3px; } .hide_spoil { background-color: grey; color: grey; } .hide_spoil img { border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 50px; white-space: nowrap; width: 1px; background-color: grey; } .hide_spoil img { content: " "; } .attach_box { background-color: #5a5555; background-color: rgb(71,71,76); border-radius: 3px; padding: 16px; overflow-wrap: break-word; } .formrow.real_first_child, .formrow:first-child { margin-top: 8px; } .formrow.real_first_child .formitem, .formrow:first-child .formitem { padding-top: 12px; } .formrow:last-child .formitem { padding-bottom: 12px; } .login_button_row { display: flex; } .login_button_row .formitem > * { padding-top: 5px; } .fall_opts { display: flex; } .dont_have_account { margin-left: auto; padding-right: 0px; } .dont_have_account:after { content: "|"; padding-left: 8px; padding-right: 8px; } .forgot_password { padding-left: 0px; } .formitem.dont_have_account, .formitem.forgot_password { color: #909090; font-size: 12px; font-weight: normal; padding-top: 11px; } textarea { background-color: var(--input-background-color); border-color: var(--input-border-color); color: var(--input-text-color); width: calc(100% - 15px); min-height: 80px; } textarea:focus, input:focus, select:focus, button:focus { outline-color: rgb(95,95,95); } textarea.large { min-height: 120px; margin-top: 1px; padding: 5px; display: block; } .formitem button, .formbutton, .mod_floater_submit, .pane_buttons button { background-color: var(--input-background-color); border: 1px solid var(--input-border-color); color: var(--input-text-color); padding: 7px; padding-bottom: 6px; font-size: 13px; } .mod_floater_submit { padding: 5px; padding-bottom: 4px; margin-left: 2px; } .pane_buttons button { padding: 5px; padding-bottom: 4px; } .formrow { flex-direction: row; display: flex; } .formitem { margin-top: 0px; padding-bottom: 2px; padding-top: 3px; flex-grow: 2; } .formlabel { flex-grow: 0; width: 20%; padding-top: 9px; } /* If the form label is on the right */ .formlabel:not(:first-child) { font-size: 15px; flex-grow: 2; } .formrow.real_first_child .formlabel, .formrow:first-child .formlabel { padding-top: 17px; } /* Too big compared to the other items in the Control Panel and Account Panel */ /*.colstack_item .formrow.real_first_child, .colstack_item .formrow:first-child { margin-top: 8px; }*/ .colstack_item .formrow.real_first_child, .colstack_item .formrow:first-child { margin-top: 3px; } .thin_margins .formrow.real_first_child, .thin_margins .formrow:first-child { margin-top: 5px; } .formitem a { font-size: 14px; } .rowmenu .rowitem, .rowlist .rowitem, .rowlist .formitem { margin-top: 3px; font-size: 13px; padding: 10px; } .menu_stats { font-size: 12px; } /* Mini paginators aka panel paginators */ .pageset { margin-top: 4px; display: flex; flex-direction: row; margin-left: 8px; margin-bottom: 8px; } .pageitem { background-color: var(--main-block-color); padding: 10px; margin-right: 4px; font-size: 13px; } .bgsub { display: none; } .rowlist.bgavatars .rowitem { background-repeat: no-repeat; background-size: 40px; padding-left: 46px; } .bgavatars:not(.rowlist) .rowitem { background-repeat: no-repeat; background-size: 40px; padding-left: 46px; } .rowlist .formrow, .rowlist .formrow:first-child { margin-top: 0px; } .loglist .to_left small { margin-left: 2px; font-size: 12px; } .loglist .to_right span { font-size: 14px; } input { background-color: var(--input-background-color); border: 1px solid var(--input-border-color); color: var(--input-text-color); padding-bottom: 6px; font-size: 13px; padding: 5px; width: calc(100% - 16px); } select { background-color: var(--input-background-color); border: 1px solid var(--input-border-color); color: var(--input-text-color); font-size: 13px; padding: 4px; } .rowlist .formitem select { padding: 2px; font-size: 11px; margin-top: -5px; } input, select, textarea { caret-color: rgb(95,95,95); } .form_middle_button { margin-left: auto; margin-right: auto; display: block; margin-top: 5px; } .little_row_avatar { display: none; } .topic_create_form .topic_board_row .formitem, .topic_create_form .topic_name_row .formitem { padding-bottom: 5px; } .topic_create_form input, .topic_create_form select { padding: 7px; font-size: 13px; } .topic_create_form select { padding: 6px; } .topic_create_form input { width: calc(100% - 14px); } .topic_create_form textarea, .topic_reply_form textarea { width: calc(100% - 26px); min-height: 80px; font-family: arial; font-size: 14px; padding: 12px; } .topic_create_form textarea { padding: 7px; width: calc(100% - 14px); } .quick_button_row .formitem, .quick_create_form .upload_file_dock { display: flex; } .quick_create_form .add_file_button, .quick_create_form #add_poll_button { margin-left: 8px; } .quick_create_form .close_form { margin-left: auto; } .quick_create_form .uploadItem { display: inline-block; margin-left: 8px; background-size: 25px 30px; background-repeat: no-repeat; padding-left: 30px; } .footBlock { margin-top: -2px; display: flex; } .footer { width: 70%; margin-left: auto; margin-right: auto; } .elapsed { display: none; } #poweredByHolder { background-color: var(--main-block-color); padding: 10px; font-size: 14px; padding-left: 13px; padding-right: 13px; clear: left; height: 25px; } #poweredByHolder select { background-color: var(--input-background-color); border: 1px solid var(--input-border-color); color: var(--input-text-color); font-size: 13px; padding: 4px; } #poweredBy { float: left; margin-top: 4px; } #poweredBy span { font-size: 12px; } #themeSelector { float: right; } .poll_item { display: flex; } .poll_option { margin-bottom: 3px; } input[type=checkbox] { display: none; } input[type=checkbox] + label { display: inline-block; width: 12px; height: 12px; margin-bottom: -2px; border: 1px solid var(--bright-input-border-color); background-color: var(--bright-input-background-color); } input[type=checkbox]:checked + label .sel { display: inline-block; width: 5px; height: 5px; background-color: var(--bright-input-background-color); } input[type=checkbox] + label.poll_option_label { width: 14px; height: 14px; margin-right: 3px; background-color: var(--bright-input-background-color); border: 1px solid var(--bright-input-border-color); color: var(--bright-input-text-color); } input[type=checkbox]:checked + label.poll_option_label .sel { display: inline-block; width: 10px; height: 10px; margin-left: 3px; background: var(--bright-input-border-color); } .pollinput { display: flex; margin-bottom: 8px; } .quick_create_form .pollinputlabel { display: none; } /*#poll_option_text_0 { color: hsl(359,98%,43%); }*/ .poll_buttons { margin-top: 12px; } .poll_buttons button { background-color: var(--bright-input-background-color); border: 1px solid var(--bright-input-border-color); color: var(--bright-input-text-color); padding: 7px; padding-bottom: 6px; font-size: 13px; } .poll_buttons > *:not(:first-child) { margin-left: 5px; } .poll_results { margin-left: auto; max-height: 120px; } /* Forum View */ .rowhead, .opthead, .colstack_head, .rowhead .rowitem { display: flex; flex-direction: row; } .rowhead:not(.has_opt) .rowitem, .opthead .rowitem, .colstack_head .rowitem { width: 100%; } .optbox { display: flex; padding-left: 5px; padding-top: 10.5px; margin-top: 7px; width: 100%; background-color: var(--main-block-color); } .has_opt .rowitem { margin-right: 0px; display: inline-block; padding-right: 0px; margin-top: 7px; padding-left: 12px; padding-top: 12px; } .opt a { font-size: 11px; } .topic_list_title_block .pre_opt:before { content: "{{lang "topics_click_topics_to_select" . }}"; font-size: 14px; } .create_topic_opt a:before { content: "{{lang "topics_new_topic" . }}"; margin-left: 3px; } .locked_opt a:before { content: "{{lang "forum_locked" . }}"; } .mod_opt a { margin-left: 4px; } .mod_opt a:after { content: "{{lang "topics_moderate" . }}"; padding-left: 1px; } .topic_list_title_block .moderate_link.moderate_open:after { content: "{{lang "topic_list.cancel_mod" . }}"; } .create_topic_opt { order: 1; } .mod_opt { order: 2; } .pre_opt { order: 3; margin-left: auto; margin-right: 12px; } .filter_opt { display: none; } @keyframes fadein { from { opacity: 0; } to { opacity: 1; } } .mod_floater { position: fixed; bottom: 15px; right: 15px; width: 150px; height: 65px; font-size: 14px; padding: 14px; z-index: 9999; animation: fadein 0.8s; background-color: var(--main-block-color); } .mod_floater_head { margin-bottom: 8px; } .modal_pane { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); background-color: var(--main-block-color); border: 2px solid #333333; padding-left: 24px; padding-right: 24px; z-index: 9999; animation: fadein 0.8s; } .pane_header { font-size: 15px; } .pane_header h3 { -webkit-margin-before: 0; -webkit-margin-after: 0; margin-block-start: 0; margin-block-end: 0; margin-top: 10px; margin-bottom: 10px; font-weight: normal; } .pane_row { font-size: 14px; margin-bottom: 1px; } .pane_selected { font-weight: bold; } .pane_buttons { margin-top: 7px; margin-bottom: 8px; } .topic_list .topic_row { display: flex; } .topics_moderate .topic_row:not(.can_mod) .rowitem { background-color: hsla(0, 0%, 22%, 1); } .topics_moderate .can_mod .rowitem { background-color: hsla(0, 0%, 25%, 1); } .topics_moderate .can_mod:hover .rowitem { background-color: hsla(0, 0%, 29%, 1); } .topic_row.topic_selected .rowitem { background-color: hsla(0, 0%, 31%, 1); } /* Temporary hack, so that I don't break the topic lists of the other themes */ .topic_list .topic_inner_right { display: none; } .topic_list .rowitem { float: left; overflow: hidden; } .topic_list .topic_left { width: 100%; height: 59px; display: flex; padding: 0px; overflow: hidden; } .topic_sticky .topic_left .topic_inner_left { border-top: 4px solid hsl(41, 100%, 50%); padding-left: 10px; padding-top: 10px; margin-top: 0px; margin-left: 0px; width: 100%; } .topic_list .topic_right { height: 59px; margin-left: 8px; display: flex; width: 284px; padding: 0px; } .topic_right_inside { display: flex; } .topic_list .topic_left img, .topic_list .topic_right img { width: 64px; } .topic_list .topic_inner_left, .topic_right_inside > span { margin-left: 8px; margin-top: 12px; } .topic_right_inside .lastName { font-size: 14px; } .topic_list .topic_row:last-child { margin-bottom: 10px; } .topic_list .lastReplyAt { white-space: nowrap; } .topic_list .lastReplyAt:before { content: "{{lang "topics_last" . }}: "; } .topic_list .starter:before { content: "{{lang "topics_starter" . }}: "; } .topic_middle { display: none; } .more_topic_block_initial { display: none; } .more_topic_block_active { display: block; } .topic_name_input { width: 100%; margin-right: 10px; background-color: var(--input-background-color); border: 1px solid var(--input-border-color); color: var(--input-text-color); padding-bottom: 6px; font-size: 13px; padding: 5px; } .topic_item .submit_edit { margin-left: auto; } .topic_item .topic_status_closed { margin-left: auto; position: relative; top: -5px; } .prev_link, .next_link { display: none; } .postImage { max-width: 100%; max-height: 200px;/*300px;*/ background-color: rgb(71,71,71); padding: 10px; } video { width: 100%; } blockquote { background-color: rgb(71,71,71); margin: 0px; margin-top: 10px; padding: 10px; } blockquote:first-child { margin-top: 0px; } /* Profiles */ #profile_left_lane { width: 220px; margin-top: 5px; } #profile_left_lane .avatarRow { overflow: hidden; max-height: 220px; padding: 0; } #profile_left_lane .avatar { width: 100%; margin: 0; display: block; } #profile_left_lane .username { font-size: 14px; display: block; margin-top: 3px; } #profile_left_pane .nameRow .username { float: right; font-weight: normal; } #profile_left_pane .report_item:after { content: "{{lang "topic.report_button_text" . }}"; } #profile_left_lane .profileName { font-size: 18px; } #profile_right_lane { width: calc(100% - 245px); } #profile_right_lane .rowitem, #profile_right_lane .colstack_item .formrow.real_first_child, #profile_right_lane .colstack_item .formrow:first-child { margin-top: 5px; } .simple .user_tag { font-size: 14px; } /* TODO: Have a has_avatar class for profile comments and topic replies to allow posts without avatars? Won't that look inconsistent next to everything else for just about every theme though? */ #profile_comments .rowitem { background-repeat: no-repeat, repeat-y; background-size: 128px; padding-left: 136px; } .ip_search_block .rowitem { display: flex; flex-direction: row; } .ip_search_block input { background-color: var(--input-background-color); border: 1px solid var(--input-border-color); color: var(--input-text-color); margin-top: -3px; margin-bottom: -3px; padding: 4px; padding-bottom: 3px; } .ip_search_input { font-size: 15px; width: 100%; margin-left: 0px; } .ip_search_search { font-size: 14px; margin-left: 8px; } .level_complete, .level_future, .level_inprogress { display: flex; } .progressWrap { margin-left: auto; width: auto !important; } .colstack_grid { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 3px; grid-gap: 3px; text-align: center; } .grid_stat, .grid_istat { padding-top: 10px; padding-bottom: 10px; font-size: 13px; background-color: var(--main-block-color); } @media(max-width: 935px) { .simple .user_tag { display: none; } #profile_left_lane { width: 160px; } #profile_left_lane .avatarRow { max-height: 160px; } #profile_left_lane .profileName { font-size: 16px; } #profile_right_lane { width: calc(100% - 185px); } } @media(max-width: 830px) { #main_menu { padding-left: 10px; padding-right: 0px; height: 35px; } li { height: 26px; } #menu_overview { margin-right: 9px; margin-left: 0px; font-size: 15px; width: 32px; text-align: center; } .menu_left { margin-right: 7px; } .menu_left:not(#menu_overview) { font-size: 13px; padding-top: 10px; } .menu_alerts { padding-top: 9px; float: left; margin-right: 6px; } .alert_counter { border-radius: 8px; font-size: 0px; color: #c80000; left: 2px; } .alert_aftercounter { float: none; margin-right: 0px; font-size: 13px; padding-top: 1.5px; } .has_alerts .alert_aftercounter { position: relative; top: -5px; } .menu_alerts:not(.has_alerts) .alert_counter { display: none; } .selectedAlert .alertList { right: 10px; top: 42px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .alertItem.withAvatar { height: 28px; background-size: 38px; padding-left: 46px; padding-top: 10px; overflow: hidden; text-overflow: ellipsis; } #back, .footer { width: calc(100% - 20px); } .opthead, .rowhead, .colstack_head { padding-top: 0px !important; font-size: 15px; } .rowblock:not(.opthead):not(.colstack_head):not(.rowhead) .rowitem { font-size: 14px; } .rowsmall { font-size: 11px; } @media(min-width: 400px) { #main_menu { height: 40px; } #menu_overview { font-size: 16px; } .menu_left:not(#menu_overview) { font-size: 14px; padding-top: 13px; } .alert_aftercounter { font-size: 14px; padding-top: 4px; } } } @media(max-width: 520px) { .user_tag, .level_label, .level { display: none; } #profile_left_lane { width: 100px; } #profile_comments .rowitem { background-size: 80px; padding-left: calc(80px + 12px); } #profile_left_lane .avatarRow { max-height: 100px; } #profile_right_lane { width: calc(100% - 125px); } } @media(max-width: 500px) { .topic_list .rowitem { float: none; } .topic_list .topic_left { width: 100%; } .topic_list .topic_right, #poweredBy span { display: none; } } @media(max-width: 470px) { .like_count_label, .like_count { display: none; } .post_item { background-size: 100px; padding-left: calc(100px + 12px); } } @media(max-width: 370px) { .menu_profile { display: none; } .post_item { background-size: 80px; padding-left: calc(80px + 12px); } .controls { margin-top: 14px; } #profile_comments .rowitem { background-image: none !important; padding-left: 10px !important; } } @media(max-width: 324px) { #main_menu { padding-left: 5px; } } ================================================ FILE: themes/shadow/public/misc.js ================================================ (() => { addInitHook("end_init", () => { // TODO: Run this when the image is loaded rather than when the document is ready? $(".topic_list img").each(function(){ let aspectRatio = this.naturalHeight / this.naturalWidth; log("aspectRatio",aspectRatio); log("height",this.naturalHeight); log("width",this.naturalWidth); $(this).css({ height: aspectRatio * this.width }); }); }); })() ================================================ FILE: themes/shadow/public/panel.css ================================================ .submenu:before { content: "-"; margin-right: 6px; } .colstack_head .rowitem { display: flex; } .colstack_head .rowitem h1, .colstack_head .rowitem a { margin-right: auto; } .colstack_head .rowitem a h1 { margin-right: 0px; } .rowitem h2.hguide { font-size: 15px; } .rowlist .tag-mini { font-size: 10px; margin-left: 2px; } .analytics .colstack_head:first-child { padding-bottom: 4px; } .analytics .colstack_head select { padding: 2px; margin-top: -3px; margin-bottom: -3px; } .panel_floater { margin-left: auto; } .panel_right_button { float: right; margin-left: 5px; } .edit_button:before { content: "{{lang "panel_edit_button_text" . }}"; } .delete_button:after { content: "{{lang "panel_delete_button_text" . }}"; } #panel_dashboard_right .colstack_head .rowitem { padding: 10px; } #panel_dashboard_right .colstack_head .rowitem h1, #panel_dashboard_right .colstack_sub_head .rowitem h2 { font-size: 15px; margin-left: auto; margin-right: auto; } #panel_dashboard_right .colstack_head a, #panel_dashboard_right .colstack_sub_head a { text-align: center; width: 100%; display: block; font-size: 15px; } .grid2 { margin-top: 6px; } #panel_forums .rowitem { display: flex; } #panel_users .panel_tag { float: right; } #panel_users .ban_button { font-size: 10px; float: none; margin-left: 0px; } #panel_users .ban_button:before { content: "|"; margin-right: 4px; } #forum_quick_perms .edit_fields { float: right; } .forum_active_name { color: rgb(200,200,200); } .builtin_forum_divider { margin-bottom: 5px; } #panel_settings .panel_compactrow { padding-left: 10px; } #panel_word_filters .itemSeparator:before { content: " || "; padding-left: 2px; padding-right: 2px; } #panel_themes .rowitem::after { content: ""; display: block; clear: both; } .panel_submitrow .rowitem { display: flex; } .panel_submitrow .rowitem *:first-child { margin-left: auto; } .panel_submitrow .rowitem *:last-child { margin-right: auto; } .panel_submitrow .rowitem button { padding-top: 5px; padding-bottom: 5px; } .colstack_graph_holder { background-color: var(--main-block-color); padding: 10px; } .ct-label { color: var(--input-text-color) !important; } .ct-chart-line, .ct-grid { stroke: var(--input-text-color) !important; } .ct-series-a .ct-bar, .ct-series-a .ct-line, .ct-series-a .ct-point, .ct-series-a .ct-slice-donut { stroke: hsl(359,98%,43%) !important; } .ct-legend { margin-top: 0px; margin-bottom: 0px; } .ct-series-e .ct-bar, .ct-series-e .ct-line, .ct-series-e .ct-point, .ct-series-e .ct-slice-donut { stroke: #c73eaf !important; } .ct-series-e.ct-point { stroke: #c73eaf !important; } .ct-series-e.ct-point:hover { stroke: #c73eaf !important; } .ct-legend .ct-series-4:before { background-color: #c73eaf !important; border-color: /*#ed4cd0*/#c73eaf !important; } .ct-legend .ct-series-7:before { background-color: #6b0392 !important; border-color: #6b0392 !important; } select + .timeRangeSelector { margin-left: 8px; } #widgetTmpl, .widget_disabled { display: none; } .bg_red .widget_disabled { display: inline; } .wtypes .formrow { display: none; } .wtype_about .w_about, .wtype_simple .w_simple, .wtype_wol .w_wol, .wtype_default .w_default { display: block; } .logdetail { margin-top: 4px; } #panel_reglogs .logdetail small, #panel_reglogs .logdetails span { font-size: 12px; } .pageset { margin-left: 0px; margin-bottom: 0px; } .pageitem { padding: 8px; } ================================================ FILE: themes/shadow/public/profile.css ================================================ ================================================ FILE: themes/shadow/theme.json ================================================ { "Name": "shadow", "FriendlyName": "Shadow", "Version": "0.0.1", "Creator": "Azareal", "FullImage": "shadow.png", "URL": "github.com/Azareal/Gosora", "BgAvatars":true, "Docks":["topMenu"], "Templates": [ { "Name": "topic", "Source": "topic" }, { "Name":"topic_mini", "Source":"topic_mini" } ], "Resources": [ { "Name":"shadow/misc.js", "Location":"global", "Async":true } ] } ================================================ FILE: themes/tempra_simple/DEVELOPERS.md ================================================ # Theme Notes /public/post-avatar-bg.jpg is a solid rgb(255,255,255) white. ================================================ FILE: themes/tempra_simple/overrides/login.html ================================================ {{template "header.html" . }}

{{lang "login_head"}}

{{template "footer.html" . }} ================================================ FILE: themes/tempra_simple/public/account.css ================================================ #back { width: 100%; } .sidebar { display: none; } #account_dashboard .colstack_right .coldyn_block { display: flex; } #dash_left .rowitem { border: 1px solid hsl(0,0%,85%); } #dash_left img { display: block; height: 82px; width: 82px; margin-left: auto; margin-right: auto; margin-top: 8px; margin-bottom: 8px; } #dash_username { display: flex; height: 26px; } #dash_username input { margin-right: 8px; width: 100px; padding-left: 8px; padding-top: 4px; } #dash_username .formbutton { padding: 5px; padding-top: 4px; font-size: 14px; } #dash_avatar_buttons { display: flex; } #dash_right { width: 100%; } #dash_right .rowitem { border: 1px solid hsl(0,0%,85%); margin-left: 8px; } #dash_right .rowitem:not(:last-child) { margin-bottom: 8px; } .account_soon, .dash_security { font-size: 14px; color: maroon; } .validated_email { color: green; } .invalid_email { color: crimson; } ================================================ FILE: themes/tempra_simple/public/convo.css ================================================ .convos_item_user:not(:last-child):after { content: ","; } .to_left:after { clear: both; } /*.parti { margin-bottom: 8px; }*/ .parti .rowitem { display: flex; } .parti_user:not(:last-child):after { content: ","; } .convo_row_box .rowitem { background-repeat: no-repeat, repeat-y; background-size: 128px; padding-left: 136px; } ================================================ FILE: themes/tempra_simple/public/main.css ================================================ * { box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; } body { font-family: arial; padding-bottom: 8px; } /* Patch for Edge, until they fix emojis in arial x.x */ @supports (-ms-ime-align:auto) { .user_content { font-family: Segoe UI Emoji, arial; } } #main_menu { padding-left: 0px; padding-right: 0px; height: 36px; list-style-type: none; border: 1px solid hsl(0, 0%, 80%); background-color: rgb(252,252,252); margin-bottom: 12px; } .menu_left, .menu_right { height: 35px; padding-left: 10px; padding-top: 8px; padding-bottom: 8px; padding-right: 10px; background: white; border-bottom: 1px solid hsl(0, 0%, 80%); } .menu_left:hover, .menu_right:hover { background: rgb(252,252,252); } .menu_left a, .menu_right a { text-decoration: none; color: black; font-size: 17px; } .menu_left { float: left; border-right: 1px solid hsl(0, 0%, 80%); } .menu_right { float: right; border-left: 1px solid hsl(0, 0%, 80%); } #menu_overview { background: none; padding-right: 13px; } #menu_overview a { padding-left: 3px; } .alert_bell:before { content: '🔔︎'; } .menu_bell { cursor: default; } .menu_alerts { /*padding-left: 7px;*/ font-size: 20px; padding-top: 2px; color: rgb(80,80,80); } .menu_alerts .alert_counter { position: relative; font-size: 8px; top: -25px; background-color: rgb(190,0,0); color: white; width: 14px; left: 10px; line-height: 8px; padding-top: 2.5px; height: 14px; text-align: center; border: white solid 1px; } .menu_alerts .alert_counter:empty { display: none; } .selectedAlert { background: white; color: black; } .selectedAlert:hover { background: white; color: black; } .selectedAlert .alert_counter { display: none; } .menu_alerts .alertList { display: none; z-index: 500; } .selectedAlert .alertList { position: absolute; top: 51px; display: block; background: white; font-size: 10px; line-height: 16px; width: 300px; right: calc(5% + 7px); border-top: 1px solid hsl(0, 0%, 80%); border-left: 1px solid hsl(0, 0%, 80%); border-right: 1px solid hsl(0, 0%, 80%); border-bottom: 1px solid hsl(0, 0%, 80%); margin-bottom: 10px; } .alertItem { padding: 8px; overflow: hidden; text-overflow: ellipsis; padding-top: 17px; padding-bottom: 16px; } .alertItem.withAvatar { background-size: 60px; background-repeat: no-repeat; padding-right: 12px; padding-left: 68px; height: 50px; } .alertItem.withAvatar:not(:last-child) { border-bottom: 1px solid rgb(230,230,230); } .alertItem.withAvatar .text { overflow: hidden; text-overflow: ellipsis; float: right; height: 40px; width: 100%; white-space: nowrap; } .alertItem .text { font-size: 13px; font-weight: normal; margin-left: 5px; } .container { width: 90%; padding: 0px; margin-left: auto; margin-right: auto; } #back { display: flex; } #back, #main { width: 100%; } main > *:last-child { margin-bottom: 12px; } .rowblock { border: 1px solid hsl(0, 0%, 80%); width: 100%; padding: 0px; padding-top: 0px; } .rowblock:empty { display: none; } .rowmenu { border: 1px solid hsl(0, 0%, 80%); } .rowmenu > div:not(:last-child) { border-bottom: 1px solid hsl(0, 0%, 80%); } .rowsmall { font-size: 12px; } .colstack_left { float: left; width: 30%; margin-right: 8px; } .colstack_right { float: left; width: 65%; width: calc(70% - 15px); } .colstack_item { border: 1px solid hsl(0, 0%, 80%); padding: 0px; padding-top: 0px; width: 100%; margin-bottom: 12px; overflow: hidden; word-wrap: break-word; } .colstack_head { margin-bottom: 0px; } .colstack_left:empty, .colstack_right:empty { display: none; } .colstack_grid { display: grid; grid-template-columns: repeat(3, 1fr); grid-gap: 12px; margin-left: 5px; margin-top: 2px; } .grid_item { border: 1px solid hsl(0, 0%, 80%); word-wrap: break-word; background-color: white; width: 100%; overflow: hidden; } .grid_item a { text-decoration: none; color: black; } .grid_stat, .grid_istat { text-align: center; padding-top: 12px; padding-bottom: 12px; font-size: 16px; } /*.grid_istat { margin-bottom: 5px; }*/ .stat_green { background-color: lightgreen; border-color: lightgreen; } .stat_orange { background-color: #ffe4b3; border-color: #ffe4b3; } .stat_red { background-color: #ffb2b2; border-color: #ffb2b2; } .stat_disabled { background-color: lightgray; border-color: lightgray; } .grid2 { margin-top: 16px; } .rowhead .rowitem, .colstack_head .rowitem { background-color: rgb(252,252,252); display: flex; } .rowhead .rowitem select, .colstack_head .rowitem select { padding-top: 2px; padding-bottom: 2px; margin-top: -3px; margin-bottom: -2px; } .rowhead h1, .colstack_head h1, .rowhead h2, .colstack_head h2 { font-size: 16px; margin-left: 4px; } h1, h2, h3, h4, h5 { -webkit-margin-before:0; -webkit-margin-after:0; margin-block-start:0; margin-block-end:0; font-weight: normal; } .rowitem { width: 100%; padding-left: 10px; padding-top: 14px; padding-bottom: 12px; padding-right: 10px; background-color: white; } .rowitem:not(:last-child) { border-bottom: 1px solid hsl(0,0%,85%); } .rowitem a { text-decoration: none; color: black; } .rowitem a:hover { color: silver; } .top_post { margin-bottom: 12px; } .opthead { display: none; } .topic_list_title_block { display: flex; } .has_opt { border-bottom: 1px solid hsl(0, 0%, 80%); } .has_opt .rowitem { border-right: 1px solid hsl(0, 0%, 80%); border-bottom: none; } .optbox { margin-left: auto; } .opt { font-size: 32px; background-color: white; width: 50px; text-align: center; } .create_topic_opt a.create_topic_link:before { content: '🖊︎'; } .create_topic_opt, .create_topic_opt a { color: rgb(120,120,120); text-decoration: none; } .locked_opt { color: rgb(80,80,80); } .locked_opt:before { content: '🔒︎'; } /*.mod_opt a.moderate_link:before { content: '🔨︎'; } .mod_opt, .mod_opt a { color: rgb(120,120,120); text-decoration: none; }*/ .filter_opt { display: none; } .to_left { float: left; } .to_right { margin-left: auto; float: right; } .rowlist { font-size: 15px; } .datarow, .rowlist .rowitem { padding-top: 10px; padding-bottom: 10px; } .loglist .to_left small { margin-left: 2px; font-size: 12px; } .loglist .to_right span { font-size: 14px; } .bgsub { display: none; } .bgavatars .rowitem { background-repeat: no-repeat; background-size: 40px; padding-left: 46px; } .formrow { width: 100%; background-color: white; } /* Clearfix */ .formrow:before, .formrow:after { content: " "; display: table; } .formrow:after { clear: both; } .formrow:not(:last-child) { border-bottom: 1px dotted hsl(0, 0%, 80%); } .formitem { float: left; padding: 10px; min-width: 20%; font-weight: normal; } .formitem:not(:last-child) { border-right: 1px dotted hsl(0, 0%, 80%); } .formitem.invisible_border { border: none; } input, select { padding: 3px; } /* Mostly for textareas */ .formitem:only-child { width: 100%; } .formitem:only-child select { padding: 1px; margin-top: -1px; margin-bottom: -1px; } .formitem textarea { width: 100%; height: 100px; outline-color: #8e8e8e; } .formitem:has-child() { margin: 0 auto; float: none; } .formitem:not(:only-child).formlabel { padding-top: 15px; padding-bottom: 12px; } .formbutton, button, input[type="submit"] { background: white; border: 1px solid #8e8e8e; } .formbutton { padding: 7px; display: block; margin-left: auto; margin-right: auto; font-size: 15px; } .formbutton, ip_search_search { border-color: hsl(0, 0%, 80%); } .fall_opts { float: right; display: flex; } .dont_have_account, .forgot_password { color: #505050; font-size: 14px; margin-top: 6px; border-right: none !important; } .dont_have_account:after { content: "|"; margin-left: 5px; margin-right: 5px; } .dont_have_account { padding-right: 0px; } .forgot_password { padding-left: 0px; } .ip_search_block { border-bottom: none; } .ip_search_block .rowitem { display: flex; } .ip_search_input { width: 100%; } .ip_search_search { margin-left: 10px; } /* TODO: Add the avatars to the forum list */ .forum_list .forum_nodesc { font-style: italic; } .extra_little_row_avatar { display: none; } .shift_left { float: left; } .shift_right { float: right; } /* Topics */ .topic_list { border-bottom: none; } .topic_list .topic_row { display: grid; grid-template-columns: calc(100% - 204px) 204px; } .topic_list .rowitem { border-bottom: 1px solid hsl(0,0%,85%); } .topic_list .topic_inner_right { display: none; } .topic_list .lastReplyAt { white-space: nowrap; } .topic_list .lastReplyAt:before { content: "{{lang "topics_last" . }}: "; } .topic_list .starter:before { content: "{{lang "topics_starter" . }}: "; } @supports not (display: grid) { .topic_list .rowitem { float: left; overflow: hidden; } .topic_list .topic_left { width: calc(100% - 204px); } .topic_list .topic_right { width: 204px; } } .topic_left, .topic_right { display: flex; padding: 0px; height: 58px; overflow: hidden; } .topic_right_inside { display: flex; } .topic_left img, .topic_right_inside img { width: 64px; height: auto; } .topic_left .topic_inner_left, .topic_right_inside > span { margin-top: 10px; margin-left: 8px; } .topic_right_inside .lastName { font-size: 14px; } .topic_middle { display: none; } .more_topic_block_initial { display: none; } .more_topic_block_active { display: block; } .postImage { max-width: 100%; max-height: 200px; background-color: white; padding: 10px; } video { width: 100%; } /*blockquote { background-color: #EEEEEE; padding: 12px; margin: 0px; } .staff_post blockquote { background-color: rgba(255, 214, 255, 1); }*/ .little_row_avatar { display: none; } .quick_create_form .quick_button_row .formitem { display: flex; } .quick_create_form .formbutton:first-child, .quick_create_form .formbutton:not(:first-child) { margin-left: 0px; margin-right: 5px; } .quick_create_form .formbutton:last-child { margin-left: auto; } .quick_create_form .upload_file_dock { display: flex; } .quick_create_form .uploadItem { display: inline-block; margin-left: 8px; margin-right: 8px; background-size: 25px 35px; background-repeat: no-repeat; padding-left: 30px; } .username, .panel_tag { text-transform: none; margin-left: 0px; padding-left: 4px; padding-right: 4px; padding-top: 2px; padding-bottom: 2px; color: #505050; /* 80,80,80 */ background-color: #FFFFFF; border-style: solid; border-color: hsl(0, 0%, 80%); border-width: 1px; font-size: 15px; } .topic_item { display: flex; } .topic_status_sticky { display: none; } .topic_status_closed { margin-left: auto; margin-top: -5px; font-size: 0.90em; margin-bottom: -2px; } .topic_sticky .topic_left, .topic_sticky .topic_right { background-color: rgb(255,255,234); } .topic_closed .topic_left, .topic_closed .topic_right { background-color: rgb(248,248,248); } .topic_sticky_head { background-color: #FFFFEA; } .topic_closed_head { background-color: #eaeaea; } .topic_status { text-transform: none; margin-left: 8px; padding-left: 2px; padding-right: 2px; padding-top: 2px; padding-bottom: 2px; background-color: #E8E8E8; /* 232,232,232. All three RGB colours being the same seems to create a shade of gray */ color: #505050; /* 80,80,80 */ border-radius: 2px; } .topic_status:empty { display: none; } button.username { position: relative; top: -0.25px; } .username.level { color: #303030; } .username.real_username { color: #404040; font-size: 16px; padding-left: 5px; padding-right: 5px; padding-top: 3px; padding-bottom: 3px; } .username.real_username:hover { color: black; } .post_item > .username { margin-top: 20px; display: inline-block; } .post_item > .mod_button > button { font-size: 15px; color: #202020; opacity: 0.7; } .post_item > .mod_button > button:hover { opacity: 0.9; } .user_content h2 { font-size: 19px; } .user_content h3 { font-size: 18px; } .user_content h4 { font-size: 17px; } .user_content h2, .user_content h3 { margin-bottom: 12px; } .user_content h4 { margin-bottom: 8px; } .user_content strong h2, .user_content strong h3, .user_content strong h4 { font-weight: bold; } red { color: red; } .user_tag { float: right; color: #505050; font-size: 16px; } .post_item { background-size: 128px; padding-left: 136px; } .staff_post { background-color: #ffeaff; } .update_buttons .add_file_button { display: none; } .mod_button { margin-right: 4px; } .like_count_label, .like_count { display: none; } .has_likes .like_count_label, .has_likes .like_count { display: block; } .like_label:before, .like_count_label:before { content: "😀"; } .like_count_label { color: #505050; float: right; opacity: 0.85; margin-left: 5px; } .like_count { float: right; color: #505050; border-left: none; padding-left: 5px; padding-right: 5px; font-size: 17px; } .quote_label:before { content: "💬"; } .edit_label:before { content: "🖊️"; } .delete_label:before { content: "🗑️"; } .pin_label:before, .unpin_label:before { content: "📌"; } .remove_like, .unpin_label, .unlock_label { background-color: #D6FFD6; } .lock_label:before, .unlock_label:before { content: "🔒"; } .ip_label:before { content: "🔍"; } .flag_label:before { content: "🚩"; } .level_label:before { content: "👑"; } .level_label { color: #505050; opacity: 0.85; float: right; } .level_hideable { display: none; } .controls { margin-top: 23px; display: inline-block; width: 100%; } .action_item { padding: 14px; text-align: center; background-color: rgb(255,245,245); } .action_item .action_icon { font-size: 18px; padding-right: 5px; } .hide_spoil { background-color: rgb(220,220,220); color: rgb(220,220,220) !important; } .hide_spoil img { border: 0; clip: rect(0 0 0 0); height: 1px; margin: -1px; overflow: hidden; padding: 50px; white-space: nowrap; width: 1px; background-color: rgb(220,220,220); } .hide_spoil img { content: " "; } .staff_post .hide_spoil { background-color: rgb(240,180,240); /*rgb(255, 234, 255)*/ color: rgb(240,180,240) !important; } .staff_post .hide_spoil img { background-color: rgb(240,180,240); } .attach_box { border: 1px solid hsl(10, 0%, 80%); background: white; padding: 12px; margin: 0px; display: inline-block; width: 100%; margin-top: 8px; margin-bottom: 8px; overflow-wrap: break-word; } .attach_box:first-child { margin-top: 0px; } blockquote { border: 1px solid hsl(0, 0%, 80%); background: white; padding: 5px; margin: 0px; display: inline-block; width: 100%; margin-top: 8px; margin-bottom: 8px; } blockquote:first-child { margin-top: 0px; } .level { float: right; color: #505050; border-left: none; padding-left: 5px; padding-right: 5px; font-size: 17px; } .mention { font-weight: bold; } .show_on_edit:not(.edit_opened), .hide_on_edit.edit_opened, .show_on_block_edit:not(.edit_opened), .hide_on_block_edit.edit_opened, .auto_hide, .hide_on_big, .show_on_mobile, .link_select:not(.link_opened) { display: none; } input[type=checkbox] { display: none; } input[type=checkbox] + label { display: inline-block; width: 12px; height: 12px; margin-bottom: -2px; border: 1px solid hsl(0, 0%, 80%); background-color: white; } input[type=checkbox]:checked + label .sel { display: inline-block; width: 5px; height: 5px; background-color: white; } input[type=checkbox] + label.poll_option_label { width: 18px; height: 18px; margin-right: 2px; background-color: white; border: 1px solid hsl(0, 0%, 70%); color: #505050; } input[type=checkbox]:checked + label.poll_option_label .sel { display: inline-block; width: 10px; height: 10px; margin-left: 3px; background: hsl(0,0%,70%); } .poll_option { margin-bottom: 1px; } .poll_item { display: flex; padding-left: 8px; background: none !important; } .poll_buttons button { margin-top: 8px; padding: 5px; padding-top: 3px; padding-bottom: 3px; border: 1px solid hsl(0, 0%, 70%); } .poll_buttons > *:not(:first-child) { margin-left: 5px; } .poll_results { margin-left: auto; } .quick_create_form .pollinputlabel { display: none; } /* TODO: Can we just set .alert on the alert_success and .alert_error ones? */ .alert, .alert_success, .alert_error { display: block; padding: 5px; margin-bottom: 10px; } .alert { border: 1px solid hsl(0, 0%, 80%); } .alert_success { border: 1px solid #A2FC00; background-color: #DAF7A6; } .alert_error { border: 1px solid #FF004B; margin-bottom: 8px; background-color: #FEB7CC; } .prev_button, .next_button { position: fixed; top: 50%; font-size: 30px; border-width: 1px; background-color: #FFFFFF; border: 1px solid hsl(0,0%,80%); padding: 0px; padding-left: 5px; padding-right: 5px; z-index: 100; } .prev_button a, .next_button a { line-height: 28px; margin-top: 2px; margin-bottom: 0px; display: block; text-decoration: none; color: #505050; padding: 2px; } .prev_button { left: 14px; } .next_button { right: 14px; } .head_tag_upshift { float: right; position: relative; top: -2px; } .elapsed { display: none; } #poweredByHolder { border: 1px solid hsl(0, 0%, 80%); margin-top: 12px; clear: both; height: 40px; padding: 6px; padding-left: 10px; padding-right: 10px; } #poweredByHolder select { padding: 2px; margin-top: 1px; } #poweredBy { float: left; margin-top: 4px; } #poweredBy span { font-size: 12px; } #poweredByName { color: black; text-decoration: none; } #themeSelector { float: right; } .sidebar .rowhead:not(:first-child) { margin-top: 12px; } .widget_search { margin-bottom: 8px; } #profile_comments .rowitem { background-repeat: no-repeat, repeat-y; background-size: 128px; padding-left: 136px; } /* Profiles */ #profile_left_lane { width: 220px; } #profile_left_pane { margin-bottom: 12px; } #profile_left_lane .avatarRow { overflow: hidden; max-height: 220px; padding: 0; } #profile_left_lane .avatar { width: 100%; margin: 0; display: block; } #profile_left_lane .username { font-size: 14px; display: block; margin-top: 3px; } #profile_left_pane .nameRow .username { float: right; font-weight: normal; } #profile_left_lane .profileName { font-size: 18px; } #profile_left_lane .report_item:after { content: "{{lang "topic.report_button_text" . }}"; } #profile_right_lane { width: calc(100% - 245px); } #profile_comments { overflow: hidden; border-top: none; margin-bottom: 0; } .simple .user_tag { font-size: 14px; } .pageset { display: flex; /*margin-bottom: 10px;*/ margin-top: 8px; margin-bottom: 2px; } .pageitem { background-color: white; padding: 5px; margin-right: 5px; padding-bottom: 4px; border: 1px solid hsl(0, 0%, 80%); } .pageitem a { color: black; text-decoration: none; } .colstack_right .pageset { margin-top: -5px; } .level_complete, .level_future, .level_inprogress { display: flex; } #profile_left_pane .level_hideable, .levelBit .level_hideable { display: inline; } .progressWrap { margin-left: auto; width: auto !important; } {{template "media.partial.css" }} ================================================ FILE: themes/tempra_simple/public/media.partial.css ================================================ @media(min-width: 881px) { .shrink_main { float: left; /*width: calc(75% - 12px);*/ } .sidebar { float: left; width: 25%; margin-left: 12px; } } @media (max-width: 880px) { li { height: 29px; font-size: 15px; padding-left: 9px; padding-top: 6px; padding-bottom: 6px; } ul { height: 30px; margin-top: 14px; } .menu_left, .menu_right { padding-right: 9px; } .menu_alerts { padding-left: 7px; padding-right: 7px; font-size: 18px; } body { padding-left: 12px; padding-right: 12px; margin: 0px !important; width: 100% !important; height: 100% !important; overflow-x: hidden; } .container { width: auto; } .sidebar { display: none; } .selectedAlert .alertList { top: 37px; right: 4px; } } @media (max-width: 680px) { li { padding-left: 5px; padding-top: 3px; padding-bottom: 2px; height: 25px; } li a { font-size: 14px; } ul { height: 26px; } .menu_left, .menu_right { padding-right: 7px; } .menu_alerts { padding-left: 4px; padding-right: 4px; font-size: 16px; padding-top: 1px; } .selectedAlert .alertList { top: 33px; } .hide_on_mobile { display: none !important; } .prev_button, .next_button { top: auto; bottom: 5px; } .colstack_grid { grid-template-columns: none; grid-gap: 8px; } .grid_istat { margin-bottom: 0px; } } @media(max-width: 550px) { #poweredByDash, #poweredByMaker { display: none; } } @media (max-width: 500px) { .topic_list .topic_row { grid-template-columns: calc(100% - 194px) 194px; } } @media (max-width: 470px) { .menu_overview, .menu_profile { display: none; } .selectedAlert .alertList { width: 135px; margin-bottom: 5px; } .selectedAlert.hasAvatars .alertList { width: calc(100% - 8px); } .alertItem.withAvatar { background-size: 36px; text-align: right; padding-left: 10px; height: 46px; } .hasAvatars > .alertList > .alertItem.withAvatar { background-size: 46px; text-align: inherit; padding-left: 56px; height: 42px; } .alertItem { padding: 8px; } .hasAvatars > .alertList > .alertItem { padding-top: 11px; } .selectedAlert:not(.hasAvatars) > .alertList > .alertItem.withAvatar .text { width: calc(100% - 20px); height: 30px; white-space: normal; } .selectedAlert:not(.hasAvatars) > .alertList > .alertItem .text { font-size: 10px; font-weight: bold; margin-left: 0px; } .alertActive { opacity: 0.7; } .hide_on_micro { display: none !important; } .post_container { overflow: visible !important; } .post_item:not(.action_item) { background-position: 0px 2px !important; background-size: 64px auto !important; padding-left: 2px !important; min-height: 96px; position: relative !important; } .post_item > .user_content { margin-left: 75px !important; width: 100% !important; min-height: 45px; } .post_item > .controls > .mod_button { float: right !important; margin-left: 2px !important; margin-right: 3px; } .post_item > .controls > .mod_button > button { opacity: 1; padding-left: 3px; padding-right: 3px; } .post_item > .controls > .real_username { margin-top: 0px; margin-right: 0px; font-size: 15px; color: black; max-width: 61px; text-overflow: ellipsis; } .post_item > .controls { margin-top: 0px; margin-left: 74px; width: calc(100% - 74px); } .rowtopic { font-size: 14px; } .container { width: 100% !important; } } @media(max-width: 390px) { .topic_list .topic_row { display: block; } .topic_row .topic_right { display: none; } } @media (max-width: 330px) { li { padding-left: 6px; } .menu_left { padding-right: 6px; } .post_item > .controls > .real_username { display: inline-block; overflow: hidden; margin-right: -3px; text-overflow: clip; max-width: 84px; } .post_item > .controls { margin-left: 72px; } .top_post > .post_item > .controls > .real_username { max-width: 57px; } .top_post > .post_item { padding-right: 4px; } } ================================================ FILE: themes/tempra_simple/public/misc.js ================================================ (() => { addInitHook("end_init", () => { // TODO: Run this when the image is loaded rather than when the document is ready? $(".topic_list img").each(function(){ let aspectRatio = this.naturalHeight / this.naturalWidth; log("aspectRatio",aspectRatio); log("height",this.naturalHeight); log("width",this.naturalWidth); $(this).css({ height: aspectRatio * this.width }); }); }); })() ================================================ FILE: themes/tempra_simple/public/panel.css ================================================ #back { width: 100%; } .sidebar { display: none; } .submenu:before { content: "-"; } .submenu a { margin-left: 8px; } /*.colstack_right .colstack_head .rowitem { display: flex; }*/ .colstack_right .colstack_head h1 + h2.hguide { margin-left: auto; } .edit_button:before { content: "{{lang "panel_edit_button_text" . }}"; } .delete_button:after { content: "{{lang "panel_delete_button_text" . }}"; } .tag-mini { text-transform: none; margin-left: 0px; padding-left: 3px; padding-right: 3px; padding-top: 1.5px; padding-bottom: 0px; color: #505050 !important; /* 80,80,80 */ background-color: #FFFFFF; border-style: solid; border-color: #ccc; border-width: 1px; font-size: 10px; } .panel_compactrow .panel_tag, .panel_compacttext { font-size: 14px; } .panel_compactrow { padding-left: 10px; padding-top: 10px; padding-bottom: 10px; padding-right: 10px; } .panel_upshift { font-size: 18px; position: relative; top: -2px; } .panel_compactrow .panel_upshift { font-size: 16px; position: static; } .panel_upshift:visited { color: black; } .panel_floater, .panel_buttons { margin-left: auto; float: right; } #panel_forums .rowitem { display: flex; } #panel_forums .panel_floater { margin-left: auto; margin-top: -2px; } #panel_forums .panel_buttons { margin-left: 3px; } #panel_users .panel_floater { margin-left: 2px; float: none; } #panel_users .panel_tag { float: right; margin-top: -3px; } .panel_rank_tag_admin:before { content:"👑"; } .panel_rank_tag_mod:before { content:"👮"; } .panel_rank_tag_banned:before { content:"⛓️"; } .panel_rank_tag_guest:before { content:"👽"; } .panel_rank_tag_member:before { content:"👪"; } .forum_preset_announce:before { content:"📣"; } .forum_preset_members:before { content:"👪"; } .forum_preset_staff:before { content:"👮"; } .forum_preset_admins:before { content:"👑"; } .forum_preset_archive:before { content:"☠️"; } .forum_preset_all, .forum_preset_custom, .forum_preset_ { display: none !important; } .forum_active_Hide:before { content: "🕵️"; } .forum_active_Show { display: none !important; } .forum_active_name { color: #707070; } .builtin_forum_divider { border-bottom-style: solid; } .perm_preset_no_access:before { content: "{{lang "panel_perms_no_access" . }}"; color: maroon; } .perm_preset_read_only:before, .perm_preset_can_post:before { color: green; } .perm_preset_read_only:before { content: "{{lang "panel_perms_read_only" . }}"; } .perm_preset_can_post:before { content: "{{lang "panel_perms_can_post" . }}"; } .perm_preset_can_moderate:before { content: "{{lang "panel_perms_can_moderate" . }}"; color: darkblue; } .perm_preset_quasi_mod:before { content: "{{lang "panel_perms_quasi_mod" . }}"; color: darkblue; } .perm_preset_custom:before { content: "{{lang "panel_perms_custom" . }}"; color: black; } .perm_preset_default:before { content: "{{lang "panel_perms_default" . }}"; } #panel_dashboard_right .colstack_head { display: none; } .panel_submitrow { margin-top: -12px; border-top: none; } .panel_submitrow button { padding-top: 3px; padding-bottom: 3px; } #panel_settings .panel_compactrow { padding-left: 10px; } #panel_word_filters .itemSeparator:before { content: " || "; padding-left: 2px; padding-right: 2px; } .ct_chart { padding-left: 10px; padding-top: 14px; padding-bottom: 4px; padding-right: 10px; margin-bottom: 12px; background-color: white; border: 1px solid hsl(0,0%,85%); } .ct-label { fill: rgba(0,0,0,.6) !important; color: rgba(0,0,0,.6) !important; } .ct-grid { stroke: rgba(0,0,0,.3) !important; } .ct-legend { margin-top: 0px; margin-bottom: 0px; } .ct-legend .ct-series-7:before { background-color: #6b0392 !important; border-color: #6b0392 !important; } select + .timeRangeSelector { margin-left: 8px; } #widgetTmpl, .widget_disabled { display: none; } .bg_red .widget_disabled { display: inline; } .wtypes .formrow { display: none; } .wtype_about .w_about, .wtype_simple .w_simple, .wtype_wol .w_wol, .wtype_default .w_default { display: block; } #panel_themes .rowitem::after { content: ""; display: block; clear: both; } .logdetail { margin-top: 5px; } #panel_reglogs .logdetail small, #panel_reglogs .logdetails span { font-size: 14px; } ================================================ FILE: themes/tempra_simple/public/profile.css ================================================ #back { width: 100%; } .sidebar { display: none; } .colline { border-left: 1px solid hsl(0, 0%, 80%); padding: 10px; border-right: 1px solid hsl(0, 0%, 80%); background-color: white; } ================================================ FILE: themes/tempra_simple/public/sample.css ================================================ /* Sample CSS file injected by Tempra Simple. Doesn't do anything. */ ================================================ FILE: themes/tempra_simple/theme.json ================================================ { "Name": "tempra_simple", "FriendlyName": "Tempra Simple", "Version": "0.1.0-dev", "Creator": "Azareal", "FullImage": "tempra_simple.png", "MobileFriendly": true, "URL": "github.com/Azareal/Gosora", "BgAvatars":true, "Docks":["topMenu","rightSidebar"], "Templates": [ { "Name":"topic", "Source":"topic" }, { "Name":"topic_mini", "Source":"topic_mini" } ], "Resources": [ { "Name":"tempra_simple/misc.js", "Location":"global", "Async":true } ] } ================================================ FILE: tickloop.go ================================================ package main import ( "bytes" "database/sql" "log" "net/http/httptest" "strconv" "time" c "github.com/Azareal/Gosora/common" "github.com/Azareal/Gosora/routes" "github.com/Azareal/Gosora/uutils" "github.com/pkg/errors" ) var TickLoop *c.TickLoop func runHook(name string) error { if e := c.RunTaskHook(name); e != nil { return errors.Wrap(e, "Failed at task '"+name+"'") } return nil } func deferredDailies() error { lastDailyStr, e := c.Meta.Get("lastDaily") // TODO: Report this error back correctly... if e != nil && e != sql.ErrNoRows { return e } lastDaily, _ := strconv.ParseInt(lastDailyStr, 10, 64) low := time.Now().Unix() - (60 * 60 * 24) if lastDaily < low { if e := c.Dailies(); e != nil { return e } } return nil } func handleLogLongTick(name string, cn int64, secs int) { if !c.Dev.LogLongTick { return } dur := time.Duration(uutils.Nanotime() - cn) if dur.Seconds() > float64(secs) { log.Print("tick " + name + " completed in " + dur.String()) } } func tickLoop(thumbChan chan bool) error { tl := c.NewTickLoop() TickLoop = tl if e := deferredDailies(); e != nil { return e } if e := c.StartupTasks(); e != nil { return e } startTick := func(ch chan bool) (ret bool) { if c.Dev.HourDBTimeout { go func() { defer c.EatPanics() ch <- c.StartTick() }() return <-ch } return c.StartTick() } tick := func(name string, tasks c.TaskSet, secs int) error { tw := c.NewTickWatch() tw.Name = name tw.Set(&tw.Start, uutils.Nanotime()) tw.Run() defer tw.Stop() ch := make(chan bool) tw.OutEndChan = ch if startTick(ch) { return nil } tw.Set(&tw.DBCheck, uutils.Nanotime()) if e := runHook("before_" + name + "_tick"); e != nil { return e } cn := uutils.Nanotime() tw.Set(&tw.StartHook, cn) if e := tasks.Run(); e != nil { return e } tw.Set(&tw.Tasks, uutils.Nanotime()) handleLogLongTick(name, cn, secs) if e := runHook("after_" + name + "_tick"); e != nil { return e } tw.Set(&tw.EndHook, uutils.Nanotime()) //close(tw.OutEndChan) return nil } tl.HalfSecf = func() error { return tick("half_second", c.Tasks.HalfSec, 2) } // TODO: Automatically lock topics, if they're really old, and the associated setting is enabled. // TODO: Publish scheduled posts. tl.FifteenMinf = func() error { return tick("fifteen_minute", c.Tasks.FifteenMin, 5) } // TODO: Handle the instance going down a lot better // TODO: Handle the daily clean-up. tl.Dayf = func() error { if c.StartTick() { return nil } cn := uutils.Nanotime() if e := c.Dailies(); e != nil { return e } handleLogLongTick("day", cn, 5) return nil } tl.Secf = func() (e error) { if c.StartTick() { return nil } if e = runHook("before_second_tick"); e != nil { return e } cn := uutils.Nanotime() go func() { defer c.EatPanics() thumbChan <- true }() if e = c.Tasks.Sec.Run(); e != nil { return e } // TODO: Stop hard-coding this if e = c.HandleExpiredScheduledGroups(); e != nil { return e } // TODO: Handle delayed moderation tasks // Sync with the database, if there are any changes if e = c.HandleServerSync(); e != nil { return e } handleLogLongTick("second", cn, 3) // TODO: Manage the TopicStore, UserStore, and ForumStore // TODO: Alert the admin, if CPU usage, RAM usage, or the number of posts in the past second are too high // TODO: Clean-up alerts with no unread matches which are over two weeks old. Move this to a 24 hour task? // TODO: Rescan the static files for changes return runHook("after_second_tick") } tl.Hourf = func() error { if c.StartTick() { return nil } if e := runHook("before_hour_tick"); e != nil { return e } cn := uutils.Nanotime() jsToken, e := c.GenerateSafeString(80) if e != nil { return e } c.JSTokenBox.Store(jsToken) c.OldSessionSigningKeyBox.Store(c.SessionSigningKeyBox.Load().(string)) // TODO: We probably don't need this type conversion sessionSigningKey, e := c.GenerateSafeString(80) if e != nil { return e } c.SessionSigningKeyBox.Store(sessionSigningKey) if e = c.Tasks.Hour.Run(); e != nil { return e } if e = PingLastTopicTick(); e != nil { return e } handleLogLongTick("hour", cn, 5) return runHook("after_hour_tick") } c.CTickLoop = tl return nil } func sched() error { ws := errors.WithStack schedStr, err := c.Meta.Get("sched") // TODO: Report this error back correctly... if err != nil && err != sql.ErrNoRows { return ws(err) } if schedStr == "recalc" { log.Print("Cleaning up orphaned data.") count, err := c.Recalc.Replies() if err != nil { return ws(err) } log.Printf("Deleted %d orphaned replies.", count) count, err = c.Recalc.Forums() if err != nil { return ws(err) } log.Printf("Recalculated %d forum topic counts.", count) count, err = c.Recalc.Subscriptions() if err != nil { return ws(err) } log.Printf("Deleted %d orphaned subscriptions.", count) count, err = c.Recalc.ActivityStream() if err != nil { return ws(err) } log.Printf("Deleted %d orphaned activity stream items.", count) err = c.Recalc.Users() if err != nil { return ws(err) } log.Print("Recalculated user post stats.") count, err = c.Recalc.Attachments() if err != nil { return ws(err) } log.Printf("Deleted %d orphaned attachments.", count) } return nil } var pingLastTopicCount = 1 // TODO: Move somewhere else func PingLastTopicTick() error { g, e := c.Groups.Get(c.GuestUser.Group) if e != nil { return e } tList, _, _, e := c.TopicList.GetListByGroup(g, 1, 0, nil) if e != nil { return e } if len(tList) == 0 { return nil } w := httptest.NewRecorder() sid := strconv.Itoa(tList[0].ID) req := httptest.NewRequest("get", "/topic/"+sid, bytes.NewReader(nil)) cn := uutils.Nanotime() // Deal with the session stuff, etc. ucpy, ok := c.PreRoute(w, req) if !ok { return errors.New("preroute failed") } head, rerr := c.UserCheck(w, req, &ucpy) if rerr != nil { return errors.New(rerr.Error()) } rerr = routes.ViewTopic(w, req, &ucpy, head, sid) if rerr != nil { return errors.New(rerr.Error()) } /*if w.Code != 200 { return errors.New("topic code not 200") }*/ dur := time.Duration(uutils.Nanotime() - cn) if dur.Seconds() > 5 { c.Log("topic " + sid + " completed in " + dur.String()) } else if c.Dev.Log4thLongRoute { pingLastTopicCount++ if pingLastTopicCount == 4 { c.Log("topic " + sid + " completed in " + dur.String()) } if pingLastTopicCount >= 4 { pingLastTopicCount = 1 } } return nil } ================================================ FILE: tmp/filler.txt ================================================ This file is here so that Git will include this folder in the repository. ================================================ FILE: tmpl_client/stub.go ================================================ package tmpl import ( //"reflect" //"runtime" //"unsafe" "github.com/Azareal/Gosora/uutils" ) var GetFrag = func(name string) [][]byte { return nil } type WriteString interface { WriteString(s string) (n int, err error) } var StringToBytes = uutils.StringToBytes /* func StringToBytes(s string) (bytes []byte) { str := (*reflect.StringHeader)(unsafe.Pointer(&s)) slice := (*reflect.SliceHeader)(unsafe.Pointer(&bytes)) slice.Data = str.Data slice.Len = str.Len slice.Cap = str.Len runtime.KeepAlive(&s) return bytes } */ ================================================ FILE: tmplstub.go ================================================ package main import ( //"reflect" //"runtime" //"unsafe" "github.com/Azareal/Gosora/uutils" ) // TODO: Add a safe build mode for things like Google Appengine var GetFrag = func(name string) [][]byte { return nil } type WriteString interface { WriteString(s string) (n int, err error) } var StringToBytes = uutils.StringToBytes var BytesToString = uutils.BytesToString var Nanotime = uutils.Nanotime /* func StringToBytes(s string) (bytes []byte) { str := (*reflect.StringHeader)(unsafe.Pointer(&s)) slice := (*reflect.SliceHeader)(unsafe.Pointer(&bytes)) slice.Data = str.Data slice.Len = str.Len slice.Cap = str.Len runtime.KeepAlive(&s) return bytes } func BytesToString(bytes []byte) (s string) { slice := (*reflect.SliceHeader)(unsafe.Pointer(&bytes)) str := (*reflect.StringHeader)(unsafe.Pointer(&s)) str.Data = slice.Data str.Len = slice.Len runtime.KeepAlive(&bytes) return s } //go:noescape //go:linkname nanotime runtime.nanotime func nanotime() int64 func Nanotime() int64 { return nanotime() }*/ ================================================ FILE: update-deps-linux ================================================ echo "Updating the dependencies" { cp ./common/common_easyjson.tgo ./common/common_easyjson.go } || { echo "Failed to copy bundled generated easyjson file" } { GO111MODULE="off" go get -u github.com/mailru/easyjson/... } || { echo "Defaulting to bundled generated easyjson file" } GO111MODULE="auto" { easyjson -pkg common } || { echo "Defaulting to bundled generated easyjson file" } echo "Building the hook stub generator" go build -ldflags="-s -w" -o HookStubGen "./cmd/hook_stub_gen" echo "Running the hook stub generator" ./HookStubGen go get ================================================ FILE: update-deps.bat ================================================ @echo off echo Updating the dependencies go get -u github.com/mailru/easyjson/... if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) easyjson -pkg common if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) go get if %errorlevel% neq 0 ( pause exit /b %errorlevel% ) echo The dependencies were successfully updated pause ================================================ FILE: updater/main.go ================================================ package main import ( "bufio" "fmt" "os" "runtime" "runtime/debug" "syscall" "gopkg.in/src-d/go-git.v4" ) func main() { scanner := bufio.NewScanner(os.Stdin) // Capture panics instead of closing the window at a superhuman speed before the user can read the message on Windows defer func() { if r := recover(); r != nil { fmt.Println(r) debug.PrintStack() pressAnyKey(scanner) return } }() updater(scanner) } func pressAnyKey(scanner *bufio.Scanner) { fmt.Println("Please press enter to exit...") for scanner.Scan() { _ = scanner.Text() return } } // The bool return is a little trick to condense two lines onto one func logError(err error) bool { if err == nil { return true } fmt.Println(err) debug.PrintStack() return false } func updater(scanner *bufio.Scanner) bool { fmt.Println("Welcome to Gosora's Upgrader") fmt.Println("We're going to check for new updates, please wait patiently") repo, err := git.PlainOpen(".") if err != nil { return logError(err) } workTree, err := repo.Worktree() if err != nil { return logError(err) } err = workTree.Pull(&git.PullOptions{Force: true}) if err == git.NoErrAlreadyUpToDate { fmt.Println("You are already up-to-date") return true } else if err != nil && err != git.ErrUnstagedChanges { // fixes a bug in git where it refuses to update the files return logError(err) } err = workTree.Reset(&git.ResetOptions{Mode: git.HardReset}) if err != nil { return logError(err) } fmt.Println("Updated to the latest commit") headRef, err := repo.Head() if err != nil { return logError(err) } // Get information about the commit commit, err := repo.CommitObject(headRef.Hash()) if err != nil { return logError(err) } fmt.Println("Commit details:", commit) switch runtime.GOOS { case "windows": err = syscall.Exec("./patcher.bat", []string{}, os.Environ()) // doesn't work, need something for windows default: //linux, etc. err = syscall.Exec("./patcher-linux", []string{}, os.Environ()) } return logError(err) } ================================================ FILE: uploads/filler.txt ================================================ This file is here so that Git will include this folder in the repository. ================================================ FILE: uutils/utils.go ================================================ package uutils import ( "reflect" "runtime" "unsafe" ) // TODO: Add a safe build mode for things like Google Appengine func StringToBytes(s string) (bytes []byte) { str := (*reflect.StringHeader)(unsafe.Pointer(&s)) slice := (*reflect.SliceHeader)(unsafe.Pointer(&bytes)) slice.Data = str.Data slice.Len = str.Len slice.Cap = str.Len runtime.KeepAlive(&s) return bytes } func BytesToString(bytes []byte) (s string) { slice := (*reflect.SliceHeader)(unsafe.Pointer(&bytes)) str := (*reflect.StringHeader)(unsafe.Pointer(&s)) str.Data = slice.Data str.Len = slice.Len runtime.KeepAlive(&bytes) return s } //go:noescape //go:linkname nanotime runtime.nanotime func nanotime() int64 func Nanotime() int64 { return nanotime() }