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  [](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






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("
{{/****/}}
{{/** 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"}}
================================================
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}}
{{/** 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}}
================================================
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 **/}}
{{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}}
- {{.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 .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}}
{{/** TODO: Add a permissions check for this **/}}
{{else}}
{{end}}
{{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 **/}}
{{.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 **/}}