├── .babelrc ├── .dockerignore ├── .eslintignore ├── .eslintrc.js ├── .example.env ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── another-issue.md │ ├── bug-report.md │ └── feature-request.md ├── PULL_REQUEST_TEMPLATE.md ├── stale.yml └── workflows │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .test.env ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── GUIDELINES.md ├── LICENSE ├── Makefile ├── Procfile ├── README.md ├── SECURITY.md ├── air.conf ├── app ├── actions │ ├── actions.go │ ├── actions_test.go │ ├── billing.go │ ├── invite.go │ ├── invite_test.go │ ├── messages.go │ ├── oauth.go │ ├── oauth_test.go │ ├── post.go │ ├── post_test.go │ ├── settings.go │ ├── settings_test.go │ ├── signin.go │ ├── signin_test.go │ ├── tag.go │ ├── tag_test.go │ ├── tenant.go │ ├── tenant_test.go │ ├── user.go │ ├── user_test.go │ └── webhook.go ├── cmd │ ├── migrate.go │ ├── ping.go │ ├── routes.go │ ├── routes_test.go │ ├── server.go │ ├── signals_unix.go │ └── signals_windows.go ├── const.go ├── handlers │ ├── admin.go │ ├── admin_test.go │ ├── apiv1 │ │ ├── invite.go │ │ ├── post.go │ │ ├── post_test.go │ │ ├── tag.go │ │ ├── tag_test.go │ │ ├── user.go │ │ └── user_test.go │ ├── backup.go │ ├── billing.go │ ├── billing_test.go │ ├── common.go │ ├── common_test.go │ ├── images.go │ ├── images_test.go │ ├── notification.go │ ├── notification_test.go │ ├── oauth.go │ ├── oauth_test.go │ ├── post.go │ ├── post_test.go │ ├── settings.go │ ├── settings_test.go │ ├── signin.go │ ├── signin_test.go │ ├── signup.go │ ├── signup_test.go │ ├── tag.go │ ├── user.go │ ├── webhook.go │ └── webhooks │ │ └── paddle.go ├── jobs │ ├── email_supression_job.go │ ├── email_supression_job_test.go │ ├── job.go │ ├── job_settings.go │ ├── job_test.go │ ├── lock_expired_tenants_job.go │ ├── lock_expired_tenants_job_test.go │ ├── purge_notifications_job.go │ └── purge_notifications_job_test.go ├── metrics │ ├── metrics_fider.go │ └── metrics_http.go ├── middlewares │ ├── auth.go │ ├── auth_test.go │ ├── cache.go │ ├── cache_test.go │ ├── chain.go │ ├── chain_test.go │ ├── compress.go │ ├── compress_test.go │ ├── cors.go │ ├── cors_test.go │ ├── instrumentation.go │ ├── instrumentation_test.go │ ├── locale.go │ ├── maintenance.go │ ├── maintenance_test.go │ ├── panic.go │ ├── panic_test.go │ ├── security.go │ ├── security_test.go │ ├── session.go │ ├── session_test.go │ ├── setup.go │ ├── setup_test.go │ ├── tenant.go │ ├── tenant_test.go │ ├── user.go │ └── user_test.go ├── models │ ├── cmd │ │ ├── attachment.go │ │ ├── billing.go │ │ ├── blob.go │ │ ├── comment.go │ │ ├── email.go │ │ ├── event.go │ │ ├── http.go │ │ ├── log.go │ │ ├── mention_notification.go │ │ ├── notification.go │ │ ├── oauth.go │ │ ├── post.go │ │ ├── reaction.go │ │ ├── system.go │ │ ├── tag.go │ │ ├── tenant.go │ │ ├── user.go │ │ ├── userlist.go │ │ ├── vote.go │ │ └── webhook.go │ ├── dto │ │ ├── billing.go │ │ ├── blob.go │ │ ├── email.go │ │ ├── http.go │ │ ├── oauth.go │ │ ├── props.go │ │ ├── props_test.go │ │ ├── upload.go │ │ ├── user.go │ │ ├── userlist.go │ │ └── webhook.go │ ├── entity │ │ ├── billing.go │ │ ├── comment.go │ │ ├── email_verification.go │ │ ├── mention.go │ │ ├── mention_notification.go │ │ ├── notification.go │ │ ├── oauth.go │ │ ├── post.go │ │ ├── reaction.go │ │ ├── tag.go │ │ ├── tenant.go │ │ ├── user.go │ │ ├── user_test.go │ │ ├── vote.go │ │ └── webhook.go │ ├── enum │ │ ├── avatar.go │ │ ├── billing.go │ │ ├── email_kind.go │ │ ├── notification.go │ │ ├── oauth.go │ │ ├── post_status.go │ │ ├── subscriber.go │ │ ├── tenant_status.go │ │ ├── user_role.go │ │ ├── user_status.go │ │ ├── webhook_status.go │ │ └── webhook_type.go │ └── query │ │ ├── attachment.go │ │ ├── billing.go │ │ ├── blob.go │ │ ├── comment.go │ │ ├── email.go │ │ ├── notification.go │ │ ├── oauth.go │ │ ├── post.go │ │ ├── system.go │ │ ├── tag.go │ │ ├── tenant.go │ │ ├── user.go │ │ ├── vote.go │ │ └── webhook.go ├── pkg │ ├── assert │ │ ├── assert.go │ │ ├── assert_test.go │ │ └── bus_assert.go │ ├── backup │ │ ├── backup.go │ │ └── export.go │ ├── bus │ │ ├── bus.go │ │ ├── bus_test.go │ │ └── greeter_test.go │ ├── color │ │ └── color.go │ ├── crypto │ │ ├── md5.go │ │ ├── md5_test.go │ │ ├── sha512.go │ │ └── sha512_test.go │ ├── csv │ │ ├── csv.go │ │ ├── csv_test.go │ │ └── testdata │ │ │ ├── empty.csv │ │ │ ├── more-posts.csv │ │ │ └── one-post.csv │ ├── dbx │ │ ├── dbx.go │ │ ├── dbx_test.go │ │ ├── lock.go │ │ ├── lock_test.go │ │ ├── mapping.go │ │ ├── mapping_test.go │ │ ├── migrate.go │ │ ├── migrate_test.go │ │ ├── setup.sql │ │ ├── testdata │ │ │ ├── migration_failure │ │ │ │ ├── 210001010000_create_err.sql │ │ │ │ └── 210001010001_create_ok.sql │ │ │ └── migration_success │ │ │ │ ├── 210001010000_create.sql │ │ │ │ └── 210001010001_delete.sql │ │ └── types.go │ ├── env │ │ ├── env.go │ │ └── env_test.go │ ├── errors │ │ ├── errors.go │ │ ├── errors_test.go │ │ └── path.go │ ├── i18n │ │ ├── i18n.go │ │ └── i18n_test.go │ ├── jsonq │ │ ├── jsonq.go │ │ └── jsonq_test.go │ ├── jwt │ │ ├── jwt.go │ │ └── jwt_test.go │ ├── log │ │ ├── level.go │ │ ├── log.go │ │ ├── log_test.go │ │ ├── parse.go │ │ └── props.go │ ├── markdown │ │ ├── markdown.go │ │ ├── markdown_test.go │ │ └── text.go │ ├── mock │ │ ├── server.go │ │ ├── setup.go │ │ └── worker.go │ ├── rand │ │ ├── random.go │ │ └── random_test.go │ ├── tpl │ │ ├── funcs.go │ │ ├── template.go │ │ ├── template_test.go │ │ ├── testdata │ │ │ ├── base.html │ │ │ └── echo.html │ │ └── text.go │ ├── validate │ │ ├── general.go │ │ ├── general_test.go │ │ ├── subdomain.go │ │ ├── subdomain_test.go │ │ ├── upload.go │ │ ├── upload_test.go │ │ ├── validation.go │ │ └── validation_test.go │ ├── web │ │ ├── autocert.go │ │ ├── binder.go │ │ ├── binder_test.go │ │ ├── context.go │ │ ├── context_test.go │ │ ├── engine.go │ │ ├── engine_test.go │ │ ├── metrics.go │ │ ├── react.go │ │ ├── react_test.go │ │ ├── renderer.go │ │ ├── renderer_test.go │ │ ├── request.go │ │ ├── request_test.go │ │ ├── response.go │ │ ├── ssl.go │ │ ├── ssl_test.go │ │ ├── testdata │ │ │ ├── all-test-fider-io.crt │ │ │ ├── all-test-fider-io.key │ │ │ ├── assets.json │ │ │ ├── basic.html │ │ │ ├── canonical.html │ │ │ ├── chunk.html │ │ │ ├── empty.js │ │ │ ├── favicon.ico │ │ │ ├── home.html │ │ │ ├── home_ssr.html │ │ │ ├── logo1.png │ │ │ ├── logo2.jpg │ │ │ ├── logo3-200w.gif │ │ │ ├── logo3.gif │ │ │ ├── logo4.png │ │ │ ├── logo5.png │ │ │ ├── oauth.html │ │ │ ├── tenant.html │ │ │ ├── test-fider-io.crt │ │ │ ├── test-fider-io.key │ │ │ └── user.html │ │ └── util │ │ │ └── webutil.go │ ├── webhook │ │ └── props.go │ └── worker │ │ ├── context.go │ │ ├── worker.go │ │ └── worker_test.go ├── services │ ├── billing │ │ └── paddle │ │ │ ├── paddle.go │ │ │ └── response.go │ ├── blob │ │ ├── blob.go │ │ ├── blob_test.go │ │ ├── fs │ │ │ └── fs.go │ │ ├── s3 │ │ │ └── s3.go │ │ ├── sql │ │ │ └── sql.go │ │ └── testdata │ │ │ ├── file.txt │ │ │ ├── file2.png │ │ │ └── file3.txt │ ├── email │ │ ├── awsses │ │ │ └── awsses.go │ │ ├── email.go │ │ ├── email_test.go │ │ ├── emailmock │ │ │ └── emailmock.go │ │ ├── mailgun │ │ │ ├── sendmail.go │ │ │ ├── sendmail_test.go │ │ │ ├── service.go │ │ │ └── supression.go │ │ ├── message.go │ │ └── smtp │ │ │ ├── auth.go │ │ │ ├── auth_login.go │ │ │ ├── auth_test.go │ │ │ ├── smtp.go │ │ │ └── smtp_test.go │ ├── httpclient │ │ ├── httpclient.go │ │ └── httpclientmock │ │ │ └── httpclientmock.go │ ├── log │ │ ├── console │ │ │ └── console.go │ │ ├── file │ │ │ └── file.go │ │ └── sql │ │ │ └── sql.go │ ├── oauth │ │ ├── custom.go │ │ ├── oauth.go │ │ └── oauth_test.go │ ├── sqlstore │ │ └── postgres │ │ │ ├── attachment.go │ │ │ ├── attachment_test.go │ │ │ ├── billing.go │ │ │ ├── billing_test.go │ │ │ ├── comment.go │ │ │ ├── common.go │ │ │ ├── common_test.go │ │ │ ├── event.go │ │ │ ├── event_test.go │ │ │ ├── mention_notification.go │ │ │ ├── notification.go │ │ │ ├── notifications_test.go │ │ │ ├── oauth.go │ │ │ ├── post.go │ │ │ ├── post_test.go │ │ │ ├── postgres.go │ │ │ ├── setup_test.go │ │ │ ├── subscription_test.go │ │ │ ├── system.go │ │ │ ├── tag.go │ │ │ ├── tag_test.go │ │ │ ├── tenant.go │ │ │ ├── tenant_test.go │ │ │ ├── user.go │ │ │ ├── user_test.go │ │ │ ├── vote.go │ │ │ └── webhook.go │ ├── userlist │ │ ├── mocks │ │ │ └── mockUserQueryService.go │ │ ├── userlist.go │ │ └── userlist_test.go │ └── webhook │ │ ├── dummy_props.go │ │ └── webhook.go └── tasks │ ├── change_email.go │ ├── change_email_test.go │ ├── delete_post.go │ ├── delete_post_test.go │ ├── invites.go │ ├── invites_test.go │ ├── new_comment.go │ ├── new_comment_test.go │ ├── new_post.go │ ├── new_post_test.go │ ├── signin.go │ ├── signin_test.go │ ├── signup.go │ ├── signup_test.go │ ├── status_change.go │ ├── status_change_test.go │ ├── tasks.go │ └── userlist.go ├── docker-compose.yml ├── e2e ├── _init_.ts ├── features │ ├── server │ │ ├── http.feature │ │ ├── prometheus.feature │ │ └── ssr.feature │ └── ui │ │ └── post.feature ├── setup.ts ├── step_definitions │ ├── fns.ts │ ├── home.steps.ts │ ├── http.steps.ts │ ├── show_post.steps.ts │ └── user.steps.ts └── world.ts ├── esbuild-shim.js ├── esbuild.config.js ├── etc ├── browserstack.png ├── dev-fider-io.crt ├── dev-fider-io.key ├── homepage.png ├── logo-small.png ├── logo.png ├── privacy.md └── terms.md ├── favicon.png ├── go.mod ├── go.sum ├── index.d.ts ├── lingui.config.js ├── locale ├── README.md ├── ar │ ├── client.json │ └── server.json ├── cs │ ├── client.json │ └── server.json ├── de │ ├── client.json │ └── server.json ├── el │ ├── client.json │ └── server.json ├── en │ ├── client.json │ └── server.json ├── es-ES │ ├── client.json │ └── server.json ├── fr │ ├── client.json │ └── server.json ├── it │ ├── client.json │ └── server.json ├── ja │ ├── client.json │ └── server.json ├── ko │ ├── client.json │ └── server.json ├── locales.ts ├── nl │ ├── client.json │ └── server.json ├── pl │ ├── client.json │ └── server.json ├── pt-BR │ ├── client.json │ └── server.json ├── ru │ ├── client.json │ └── server.json ├── si-LK │ ├── client.json │ └── server.json ├── sk │ ├── client.json │ └── server.json ├── sv-SE │ ├── client.json │ └── server.json ├── tr │ ├── client.json │ └── server.json └── zh-CN │ ├── client.json │ └── server.json ├── main.go ├── migrations ├── 201701261850_create_tenants.up.sql ├── 201701281131_rename_domain.up.sql ├── 201702072040_create_ideas.up.sql ├── 201702251213_create_users.up.sql ├── 201702251620_add_tenant_cname.up.sql ├── 201703172030_add_ideas_userid.up.sql ├── 201703172115_remove_col_defaults.up.sql ├── 201703240709_remove_col_tenants.up.sql ├── 201703240710_create_comments.up.sql ├── 201703310824_remove_col_user_providers.up.sql ├── 201703310857_add_tenant_to_users.up.sql ├── 201704101854_add_ideas_number.up.sql ├── 201704112003_add_role_users.up.sql ├── 201704181821_add_supporters.up.sql ├── 201705132054_add_idea_status.up.sql ├── 201705191854_add_idea_slug.up.sql ├── 201705202300_add_idea_response.up.sql ├── 201707081055_set_cname_null.up.sql ├── 201707261949_set_cname_empty.up.sql ├── 201707271826_new_tenant_settings.up.sql ├── 201709081837_create_email_verifications.up.sql ├── 201709091228_create_email_verification_key_index.up.sql ├── 201709141944_rename_email_verification.up.sql ├── 201709241236_add_tenant_status.up.sql ├── 201709241254_add_signin_request_name_expires_on.up.sql ├── 201711152138_create_tags.up.sql ├── 201711181740_create_uniq_indexes.up.sql ├── 201712061924_unique_email.up.sql ├── 201712131842_unique_slug.up.sql ├── 201801031643_original_id.up.sql ├── 201801152006_rename_signin_requests.up.sql ├── 201801152017_add_kind_email_verification.up.sql ├── 201802061858_create_notification_tables.up.sql ├── 201802071816_seed_subscribers_table.up.sql ├── 201802231910_add_pg_trgm.up.sql ├── 201802241348_create_webnotification_tables.up.sql ├── 201803011831_add_tenant_id_everywhere.up.sql ├── 201803110836_edit_comment_columns.up.sql ├── 201804091842_add_is_private.up.sql ├── 201805061319_create_uploads.up.sql ├── 201805070759_add_logo_id.up.sql ├── 201805162034_add_custom_css.up.sql ├── 201805230000_drop_supporters_column.up.sql ├── 201805261834_add_user_status.up.sql ├── 201806191904_create_logs.sql ├── 201807122132_create_oauth_providers.sql ├── 201808152020_rename_idea_post.sql ├── 201808181931_add_api_key_users.sql ├── 201808192103_increase_key_size.sql ├── 201808291958_rename_support_vote.sql ├── 201809262108_rename_datetime_fields.sql ├── 201810022329_add_events.sql ├── 201810152035_add_comment_deleted.sql ├── 201811071547_increase_link_size.sql ├── 201812102208_create_blob_table.sql ├── 201812171813_migrate_to_blobs.sql ├── 201812201644_migrate_autocert_to_blobs.sql ├── 201812230904_user_avatar_type.sql ├── 201901042021_create_tenants_billing.sql ├── 201901072106_recreate_post_slug_index.sql ├── 201901130830_attachments.sql ├── 201904022134_lowercase_emails.sql ├── 201904091921_fix_api_users_role.sql ├── 202105161823_create_indexes.sql ├── 202107031320_add_locale_field.sql ├── 202107211126_added_allowing_email_auth.sql ├── 202108092243_create_webhooks.sql ├── 202109052023_email_supressed_at.sql ├── 202109072130_paddle_fields.sql ├── 202109272130_system_settings.sql ├── 202205082055_trusted_provider.sql ├── 202406111146_add_posts_user_id_index.sql ├── 202410122105_create_reactions_up.sql └── 202503202000_mentions_notifications.sql ├── package-lock.json ├── package.json ├── public ├── AsyncPages.tsx ├── assets │ ├── images │ │ ├── cc-amex.svg │ │ ├── cc-diners.svg │ │ ├── cc-discover.svg │ │ ├── cc-generic.svg │ │ ├── cc-jcb.svg │ │ ├── cc-maestro.svg │ │ ├── cc-mastercard.svg │ │ ├── cc-unionpay.svg │ │ ├── cc-visa.svg │ │ ├── fa-caretup.svg │ │ ├── heriocons-underline.svg │ │ ├── heroicons-at.svg │ │ ├── heroicons-bell.svg │ │ ├── heroicons-bold.svg │ │ ├── heroicons-bulletlist.svg │ │ ├── heroicons-chat-alt-2.svg │ │ ├── heroicons-check-circle.svg │ │ ├── heroicons-check.svg │ │ ├── heroicons-code.svg │ │ ├── heroicons-cog.svg │ │ ├── heroicons-dots-horizontal.svg │ │ ├── heroicons-download.svg │ │ ├── heroicons-duplicate.svg │ │ ├── heroicons-exclamation-circle.svg │ │ ├── heroicons-exclamation.svg │ │ ├── heroicons-eye.svg │ │ ├── heroicons-filter.svg │ │ ├── heroicons-h2.svg │ │ ├── heroicons-h3.svg │ │ ├── heroicons-inbox.svg │ │ ├── heroicons-information-circle.svg │ │ ├── heroicons-italic.svg │ │ ├── heroicons-light-bulb.svg │ │ ├── heroicons-lightbulb.svg │ │ ├── heroicons-menu.svg │ │ ├── heroicons-moon.svg │ │ ├── heroicons-orderedlist.svg │ │ ├── heroicons-pencil-alt.svg │ │ ├── heroicons-photograph.svg │ │ ├── heroicons-play.svg │ │ ├── heroicons-plus.svg │ │ ├── heroicons-pluscircle.svg │ │ ├── heroicons-search.svg │ │ ├── heroicons-selector.svg │ │ ├── heroicons-shieldcheck.svg │ │ ├── heroicons-smile.svg │ │ ├── heroicons-sparkles-outline.svg │ │ ├── heroicons-speakerphone.svg │ │ ├── heroicons-star.svg │ │ ├── heroicons-strike.svg │ │ ├── heroicons-sun.svg │ │ ├── heroicons-thumbsdown.svg │ │ ├── heroicons-thumbsup.svg │ │ ├── heroicons-volume-off.svg │ │ ├── heroicons-volume-on.svg │ │ ├── heroicons-x-circle.svg │ │ ├── heroicons-x.svg │ │ ├── reaction-add.svg │ │ ├── undraw-empty.svg │ │ ├── undraw-no-data.svg │ │ ├── undraw-post.svg │ │ └── undraw-public-discussion.svg │ └── styles │ │ ├── index.scss │ │ ├── reset.scss │ │ ├── tooltip.scss │ │ ├── utility.scss │ │ ├── utility │ │ ├── _theme.scss │ │ ├── colors.scss │ │ ├── display.scss │ │ ├── grid.scss │ │ ├── outline.scss │ │ ├── page.scss │ │ ├── sizing.scss │ │ ├── spacing.scss │ │ └── text.scss │ │ ├── variables.scss │ │ └── variables │ │ ├── _colors.scss │ │ ├── _dark-colors.scss │ │ ├── _functions.scss │ │ ├── _sizing.scss │ │ ├── _spacing.scss │ │ └── _text.scss ├── components │ ├── ErrorBoundary.spec.tsx │ ├── ErrorBoundary.tsx │ ├── Header.tsx │ ├── NotificationIndicator.scss │ ├── NotificationIndicator.tsx │ ├── Reactions.scss │ ├── Reactions.tsx │ ├── ReadOnlyNotice.tsx │ ├── ShowPostResponse.tsx │ ├── ShowPostStatus.tsx │ ├── ShowTag.scss │ ├── ShowTag.tsx │ ├── SignInModal.tsx │ ├── ThemeSwitcher.scss │ ├── ThemeSwitcher.tsx │ ├── UserMenu.tsx │ ├── VoteCounter.scss │ ├── VoteCounter.spec.tsx │ ├── VoteCounter.tsx │ ├── VoteCounter2.tsx │ ├── common │ │ ├── Avatar.scss │ │ ├── Avatar.tsx │ │ ├── AvatarStack.scss │ │ ├── AvatarStack.tsx │ │ ├── Button.scss │ │ ├── Button.tsx │ │ ├── DevBanner.scss │ │ ├── DevBanner.tsx │ │ ├── Dropdown.scss │ │ ├── Dropdown.tsx │ │ ├── Hint.scss │ │ ├── Hint.tsx │ │ ├── HoverInfo.scss │ │ ├── HoverInfo.tsx │ │ ├── Icon.tsx │ │ ├── Legal.tsx │ │ ├── Loader.scss │ │ ├── Loader.tsx │ │ ├── Logo.tsx │ │ ├── Markdown.scss │ │ ├── Markdown.tsx │ │ ├── Message.scss │ │ ├── Message.tsx │ │ ├── Modal.scss │ │ ├── Modal.tsx │ │ ├── Moment.tsx │ │ ├── Money.tsx │ │ ├── PageTitle.tsx │ │ ├── PoweredByFider.scss │ │ ├── PoweredByFider.tsx │ │ ├── SignInControl.scss │ │ ├── SignInControl.tsx │ │ ├── SocialSignInButton.tsx │ │ ├── Toggle.scss │ │ ├── Toggle.tsx │ │ ├── UserName.scss │ │ ├── UserName.tsx │ │ ├── form │ │ │ ├── Checkbox.scss │ │ │ ├── Checkbox.tsx │ │ │ ├── CommentEditor.scss │ │ │ ├── CommentEditor.tsx │ │ │ ├── CustomMention.ts │ │ │ ├── DisplayError.scss │ │ │ ├── DisplayError.spec.tsx │ │ │ ├── DisplayError.tsx │ │ │ ├── Field.tsx │ │ │ ├── Form.scss │ │ │ ├── Form.tsx │ │ │ ├── ImageUploader.scss │ │ │ ├── ImageUploader.tsx │ │ │ ├── ImageViewer.scss │ │ │ ├── ImageViewer.tsx │ │ │ ├── Input.scss │ │ │ ├── Input.tsx │ │ │ ├── MentionList.scss │ │ │ ├── MentionList.tsx │ │ │ ├── MultiImageUploader.scss │ │ │ ├── MultiImageUploader.tsx │ │ │ ├── RadioButton.scss │ │ │ ├── RadioButton.tsx │ │ │ ├── Select.scss │ │ │ ├── Select.tsx │ │ │ ├── Select2.scss │ │ │ ├── Select2.tsx │ │ │ ├── TextArea.scss │ │ │ ├── TextArea.tsx │ │ │ └── suggestion.ts │ │ └── index.tsx │ ├── index.tsx │ └── layout │ │ ├── Divider.scss │ │ ├── Divider.tsx │ │ ├── Stack.tsx │ │ └── index.tsx ├── hooks │ ├── index.ts │ ├── use-cache.ts │ ├── use-fider.ts │ ├── use-script.ts │ └── use-timeout.ts ├── index.tsx ├── jest.assets.ts ├── jest.setup.tsx ├── models │ ├── billing.ts │ ├── identity.ts │ ├── index.ts │ ├── notification.ts │ ├── post.ts │ ├── settings.ts │ └── webhook.ts ├── pages │ ├── Administration │ │ ├── components │ │ │ ├── AdminBasePage.scss │ │ │ ├── AdminBasePage.tsx │ │ │ ├── OAuthForm.tsx │ │ │ ├── SideMenu.scss │ │ │ ├── SideMenu.tsx │ │ │ ├── TagForm.tsx │ │ │ ├── TagListItem.tsx │ │ │ ├── billing │ │ │ │ └── CardDetails.tsx │ │ │ └── webhook │ │ │ │ ├── WebhookFailInfo.scss │ │ │ │ ├── WebhookFailInfo.tsx │ │ │ │ ├── WebhookForm.scss │ │ │ │ ├── WebhookForm.tsx │ │ │ │ ├── WebhookListItem.scss │ │ │ │ ├── WebhookListItem.tsx │ │ │ │ ├── WebhookProperties.scss │ │ │ │ ├── WebhookProperties.tsx │ │ │ │ ├── WebhookTemplateInfoModal.scss │ │ │ │ └── WebhookTemplateInfoModal.tsx │ │ ├── hooks │ │ │ └── use-paddle.ts │ │ └── pages │ │ │ ├── AdvancedSettings.page.tsx │ │ │ ├── Export.page.tsx │ │ │ ├── GeneralSettings.page.tsx │ │ │ ├── Invitations.page.tsx │ │ │ ├── ManageAuthentication.page.tsx │ │ │ ├── ManageBilling.page.tsx │ │ │ ├── ManageMembers.page.tsx │ │ │ ├── ManageTags.page.tsx │ │ │ ├── ManageWebhooks.page.tsx │ │ │ └── PrivacySettings.page.tsx │ ├── DesignSystem │ │ ├── DesignSystem.page.scss │ │ ├── DesignSystem.page.tsx │ │ └── index.ts │ ├── Error │ │ ├── Error.page.spec.tsx │ │ ├── Error.page.tsx │ │ ├── Error401.page.tsx │ │ ├── Error403.page.tsx │ │ ├── Error404.page.tsx │ │ ├── Error410.page.tsx │ │ ├── Error500.page.tsx │ │ ├── Maintenance.page.tsx │ │ ├── NotInvited.page.tsx │ │ ├── components │ │ │ └── ErrorPageWrapper.tsx │ │ └── index.ts │ ├── Home │ │ ├── Home.page.scss │ │ ├── Home.page.tsx │ │ ├── components │ │ │ ├── ListPosts.tsx │ │ │ ├── PostFilter.tsx │ │ │ ├── PostInput.tsx │ │ │ ├── PostsContainer.scss │ │ │ ├── PostsContainer.tsx │ │ │ ├── PostsSort.tsx │ │ │ ├── SimilarPosts.tsx │ │ │ └── TagsFilter.tsx │ │ └── index.ts │ ├── Legal │ │ ├── Legal.page.scss │ │ ├── Legal.page.tsx │ │ └── index.ts │ ├── MyNotifications │ │ ├── MyNotifications.page.tsx │ │ └── index.ts │ ├── MySettings │ │ ├── MySettings.page.tsx │ │ ├── components │ │ │ ├── APIKeyForm.tsx │ │ │ ├── DangerZone.tsx │ │ │ └── NotificationSettings.tsx │ │ └── index.ts │ ├── OAuthEcho │ │ ├── OAuthEcho.page.tsx │ │ └── index.ts │ ├── ShowPost │ │ ├── ShowPost.page.scss │ │ ├── ShowPost.page.tsx │ │ ├── components │ │ │ ├── CommentInput.tsx │ │ │ ├── DeletePostModal.tsx │ │ │ ├── DiscussionPanel.tsx │ │ │ ├── FollowButton.tsx │ │ │ ├── MentionSelector.scss │ │ │ ├── MentionSelector.tsx │ │ │ ├── NotificationsPanel.tsx │ │ │ ├── PostSearch.tsx │ │ │ ├── PostStatus.tsx │ │ │ ├── ResponseModal.tsx │ │ │ ├── ShowComment.tsx │ │ │ ├── TagListItem.tsx │ │ │ ├── TagsPanel.scss │ │ │ ├── TagsPanel.tsx │ │ │ ├── VoteSection.tsx │ │ │ ├── VotesModal.tsx │ │ │ └── VotesPanel.tsx │ │ └── index.ts │ ├── SignIn │ │ ├── CompleteSignInProfile.page.scss │ │ ├── CompleteSignInProfile.page.tsx │ │ ├── SignIn.page.tsx │ │ └── index.ts │ └── SignUp │ │ ├── PendingActivation.page.tsx │ │ ├── SignUp.page.tsx │ │ └── index.ts ├── services │ ├── actions │ │ ├── billing.ts │ │ ├── index.ts │ │ ├── infra.ts │ │ ├── invite.ts │ │ ├── notification.ts │ │ ├── post.ts │ │ ├── tag.ts │ │ ├── tenant.ts │ │ ├── user.ts │ │ └── webhook.ts │ ├── analytics.ts │ ├── cache.spec.ts │ ├── cache.ts │ ├── device.ts │ ├── fider.ts │ ├── http.ts │ ├── i18n.ts │ ├── index.ts │ ├── jwt.spec.ts │ ├── jwt.ts │ ├── markdown.spec.ts │ ├── markdown.ts │ ├── navigator.ts │ ├── notify.ts │ ├── querystring.spec.ts │ ├── querystring.ts │ ├── testing │ │ ├── fider.ts │ │ ├── http.ts │ │ ├── index.ts │ │ └── modal.ts │ ├── toastify.tsx │ ├── utils.spec.ts │ └── utils.ts └── ssr.tsx ├── robots.txt ├── scripts ├── git-prune-local.sh └── kill-dev.sh ├── tools.go ├── tsconfig.json ├── views ├── base.html ├── email │ ├── base_email.html │ ├── change_emailaddress_email.html │ ├── change_status.html │ ├── delete_post.html │ ├── echo_test.html │ ├── invite_email.html │ ├── new_comment.html │ ├── new_post.html │ ├── signin_email.html │ └── signup_email.html ├── index.html └── ssr.html └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env", "@babel/react", "@babel/preset-typescript"], 3 | "plugins": [ 4 | "@babel/plugin-proposal-class-properties", 5 | ["macros", { 6 | "lingui": { 7 | "version": 5 8 | } 9 | }], 10 | "@lingui/babel-plugin-lingui-macro" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | tests 3 | data 4 | output 5 | .git 6 | cover.out 7 | scripts 8 | .env 9 | dist -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | ssr.js 3 | node_modules/ 4 | package-lock.json -------------------------------------------------------------------------------- /.example.env: -------------------------------------------------------------------------------- 1 | BASE_URL=http://localhost:3000 2 | GO_ENV=development 3 | DATABASE_URL=postgres://fider:fider_pw@localhost:5555/fider?sslmode=disable 4 | JWT_SECRET=hsjl]W;&ZcHxT&FK;s%bgIQF:#ch=~#Al4:5]N;7V 2 | 3 | 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | tmp/ 3 | dist/ 4 | output/ 5 | node_modules/ 6 | .vscode/ 7 | .idea/ 8 | logs/ 9 | locale/**/*.js 10 | 11 | fider 12 | fider.exe 13 | ssr.js 14 | .env 15 | npm-debug.log 16 | 17 | profile.out 18 | debug 19 | debug.test 20 | .DS_Store 21 | cover.out 22 | coverage.tmp 23 | tsconfig.tsbuildinfo 24 | etc/*.pem 25 | fider_schema.sql 26 | fider.sql 27 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.* 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "es5", 4 | "singleQuote": false, 5 | "printWidth": 160, 6 | "tabWidth": 2, 7 | "useTabs": false 8 | } -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: ./bin/fider migrate && ./bin/fider -------------------------------------------------------------------------------- /air.conf: -------------------------------------------------------------------------------- 1 | # conf for [Air](https://github.com/cosmtrek/air) in TOML format 2 | 3 | root = "." 4 | tmp_dir = "tmp" 5 | 6 | [build] 7 | full_bin = "godotenv -f .env ./fider" 8 | cmd = "go build -o fider ." 9 | log = "server-errors.log" 10 | include_ext = ["go", "tpl", "tmpl", "html", "env", "json"] 11 | exclude_dir = ["public", "tmp", "vendor", "node_modules", "data", "tests", "output", "scripts", "dist"] 12 | delay = 500 13 | 14 | [log] 15 | time = false 16 | 17 | [color] 18 | main = "magenta" 19 | watcher = "cyan" 20 | build = "yellow" 21 | runner = "green" 22 | app = "white" -------------------------------------------------------------------------------- /app/actions/actions.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/getfider/fider/app/models/entity" 7 | "github.com/getfider/fider/app/pkg/validate" 8 | ) 9 | 10 | // Actionable is any action that the user can perform using the web app 11 | type Actionable interface { 12 | IsAuthorized(ctx context.Context, user *entity.User) bool 13 | Validate(ctx context.Context, user *entity.User) *validate.Result 14 | } 15 | 16 | // PreExecuteAction can add custom pre processing logic for any action 17 | // OnPreExecute is executed before IsAuthorized and Validate 18 | type PreExecuteAction interface { 19 | OnPreExecute(ctx context.Context) error 20 | } 21 | -------------------------------------------------------------------------------- /app/actions/messages.go: -------------------------------------------------------------------------------- 1 | package actions 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | "github.com/getfider/fider/app/pkg/i18n" 8 | ) 9 | 10 | func propertyIsRequired(ctx context.Context, fieldName string) string { 11 | displayName := i18n.T(ctx, fmt.Sprintf("property.%s", fieldName)) 12 | return i18n.T(ctx, "validation.required", i18n.Params{"name": displayName}) 13 | } 14 | 15 | func propertyIsInvalid(ctx context.Context, fieldName string) string { 16 | displayName := i18n.T(ctx, fmt.Sprintf("property.%s", fieldName)) 17 | return i18n.T(ctx, "validation.invalid", i18n.Params{"name": displayName}) 18 | } 19 | 20 | func propertyMaxStringLen(ctx context.Context, fieldName string, maxLen int) string { 21 | displayName := i18n.T(ctx, fmt.Sprintf("property.%s", fieldName)) 22 | return i18n.T(ctx, "validation.maxstringlen", 23 | i18n.Params{"name": displayName}, 24 | i18n.Params{"len": maxLen}, 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/cmd/migrate.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/getfider/fider/app/models/dto" 7 | "github.com/getfider/fider/app/pkg/bus" 8 | "github.com/getfider/fider/app/pkg/dbx" 9 | "github.com/getfider/fider/app/pkg/log" 10 | "github.com/getfider/fider/app/pkg/rand" 11 | _ "github.com/getfider/fider/app/services/log/console" 12 | ) 13 | 14 | // RunMigrate run all pending migrations on current DATABASE_URL 15 | // Returns an exitcode, 0 for OK and 1 for ERROR 16 | func RunMigrate() int { 17 | bus.Init() 18 | 19 | ctx := log.WithProperties(context.Background(), dto.Props{ 20 | log.PropertyKeyTag: "MIGRATE", 21 | log.PropertyKeyContextID: rand.String(32), 22 | }) 23 | 24 | err := dbx.Migrate(ctx, "/migrations") 25 | if err != nil { 26 | log.Error(ctx, err) 27 | return 1 28 | } 29 | return 0 30 | } 31 | -------------------------------------------------------------------------------- /app/cmd/routes_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/getfider/fider/app/pkg/assert" 7 | "github.com/getfider/fider/app/pkg/web" 8 | ) 9 | 10 | func TestGetMainEngine(t *testing.T) { 11 | RegisterT(t) 12 | 13 | r := routes(web.New()) 14 | Expect(r).IsNotNil() 15 | } 16 | -------------------------------------------------------------------------------- /app/cmd/signals_unix.go: -------------------------------------------------------------------------------- 1 | // +build !windows 2 | 3 | package cmd 4 | 5 | import ( 6 | "bytes" 7 | "fmt" 8 | "os" 9 | "runtime" 10 | "runtime/pprof" 11 | "syscall" 12 | 13 | "github.com/getfider/fider/app/pkg/web" 14 | ) 15 | 16 | var extraSignals = []os.Signal{syscall.SIGUSR1} 17 | 18 | func handleExtraSignal(s os.Signal, e *web.Engine) int { 19 | switch s { 20 | case syscall.SIGUSR1: 21 | println("SIGUSR1 received") 22 | println("Dumping process status") 23 | buf := new(bytes.Buffer) 24 | _ = pprof.Lookup("goroutine").WriteTo(buf, 1) 25 | _ = pprof.Lookup("heap").WriteTo(buf, 1) 26 | buf.WriteString("\n") 27 | buf.WriteString(fmt.Sprintf("# Worker Queue: %d\n", e.Worker().Length())) 28 | buf.WriteString(fmt.Sprintf("# Num Goroutines: %d\n", runtime.NumGoroutine())) 29 | println(buf.String()) 30 | } 31 | return -1 32 | } 33 | -------------------------------------------------------------------------------- /app/cmd/signals_windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package cmd 4 | 5 | import ( 6 | "os" 7 | 8 | "github.com/getfider/fider/app/pkg/web" 9 | ) 10 | 11 | var extraSignals = []os.Signal{} 12 | 13 | func handleExtraSignal(s os.Signal, e *web.Engine) int { 14 | return -1 15 | } 16 | -------------------------------------------------------------------------------- /app/handlers/backup.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "github.com/getfider/fider/app/pkg/backup" 5 | "github.com/getfider/fider/app/pkg/errors" 6 | "github.com/getfider/fider/app/pkg/log" 7 | "github.com/getfider/fider/app/pkg/web" 8 | ) 9 | 10 | // ExportBackupZip returns a Zip file with all content 11 | func ExportBackupZip() web.HandlerFunc { 12 | return func(c *web.Context) error { 13 | 14 | file, err := backup.Create(c) 15 | if err != nil { 16 | log.Error(c, errors.Wrap(err, "failed to create backup")) 17 | return c.Failure(err) 18 | } 19 | 20 | return c.Attachment("backup.zip", "application/zip", file.Bytes()) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/handlers/tag.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/getfider/fider/app/models/query" 7 | "github.com/getfider/fider/app/pkg/bus" 8 | "github.com/getfider/fider/app/pkg/web" 9 | ) 10 | 11 | // ManageTags is the home page for managing tags 12 | func ManageTags() web.HandlerFunc { 13 | return func(c *web.Context) error { 14 | getAllTags := &query.GetAllTags{} 15 | if err := bus.Dispatch(c, getAllTags); err != nil { 16 | return c.Failure(err) 17 | } 18 | 19 | return c.Page(http.StatusOK, web.Props{ 20 | Page: "Administration/pages/ManageTags.page", 21 | Title: "Manage Tags · Site Settings", 22 | Data: web.Map{ 23 | "tags": getAllTags.Result, 24 | }, 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/jobs/lock_expired_tenants_job_test.go: -------------------------------------------------------------------------------- 1 | package jobs_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/getfider/fider/app/jobs" 8 | "github.com/getfider/fider/app/models/cmd" 9 | . "github.com/getfider/fider/app/pkg/assert" 10 | "github.com/getfider/fider/app/pkg/bus" 11 | ) 12 | 13 | func TestLockExpiredTenantsJob_Schedule_IsCorrect(t *testing.T) { 14 | RegisterT(t) 15 | 16 | job := &jobs.LockExpiredTenantsJobHandler{} 17 | Expect(job.Schedule()).Equals("0 0 0 * * *") 18 | } 19 | 20 | func TestLockExpiredTenantsJob_ShouldJustDispatchCommand(t *testing.T) { 21 | RegisterT(t) 22 | 23 | bus.AddHandler(func(ctx context.Context, c *cmd.LockExpiredTenants) error { 24 | return nil 25 | }) 26 | 27 | job := &jobs.LockExpiredTenantsJobHandler{} 28 | err := job.Run(jobs.Context{ 29 | Context: context.Background(), 30 | }) 31 | Expect(err).IsNil() 32 | } 33 | -------------------------------------------------------------------------------- /app/jobs/purge_notifications_job.go: -------------------------------------------------------------------------------- 1 | package jobs 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/cmd" 5 | "github.com/getfider/fider/app/models/dto" 6 | "github.com/getfider/fider/app/pkg/bus" 7 | "github.com/getfider/fider/app/pkg/log" 8 | ) 9 | 10 | type PurgeExpiredNotificationsJobHandler struct { 11 | } 12 | 13 | func (e PurgeExpiredNotificationsJobHandler) Schedule() string { 14 | return "0 0 * * * *" // every hour at minute 0 15 | } 16 | 17 | func (e PurgeExpiredNotificationsJobHandler) Run(ctx Context) error { 18 | log.Debug(ctx, "deleting notifications older than 1 year") 19 | 20 | c := &cmd.PurgeExpiredNotifications{} 21 | err := bus.Dispatch(ctx, c) 22 | if err != nil { 23 | return err 24 | } 25 | 26 | log.Debugf(ctx, "@{RowsDeleted} notifications were deleted", dto.Props{ 27 | "RowsDeleted": c.NumOfDeletedNotifications, 28 | }) 29 | 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /app/jobs/purge_notifications_job_test.go: -------------------------------------------------------------------------------- 1 | package jobs_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/getfider/fider/app/jobs" 8 | "github.com/getfider/fider/app/models/cmd" 9 | . "github.com/getfider/fider/app/pkg/assert" 10 | "github.com/getfider/fider/app/pkg/bus" 11 | ) 12 | 13 | func TestPurgeExpiredNotificationsJob_Schedule_IsCorrect(t *testing.T) { 14 | RegisterT(t) 15 | 16 | job := &jobs.PurgeExpiredNotificationsJobHandler{} 17 | Expect(job.Schedule()).Equals("0 0 * * * *") 18 | } 19 | 20 | func TestPurgeExpiredNotificationsJob_ShouldJustDispatchCommand(t *testing.T) { 21 | RegisterT(t) 22 | 23 | bus.AddHandler(func(ctx context.Context, c *cmd.PurgeExpiredNotifications) error { 24 | return nil 25 | }) 26 | 27 | job := &jobs.PurgeExpiredNotificationsJobHandler{} 28 | err := job.Run(jobs.Context{ 29 | Context: context.Background(), 30 | }) 31 | Expect(err).IsNil() 32 | } 33 | -------------------------------------------------------------------------------- /app/metrics/metrics_http.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import "github.com/prometheus/client_golang/prometheus" 4 | 5 | var HttpRequests = prometheus.NewCounterVec( 6 | prometheus.CounterOpts{ 7 | Name: "http_requests_total", 8 | Help: "Number of HTTP requests.", 9 | }, 10 | []string{"code", "operation"}, 11 | ) 12 | 13 | var HttpDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 14 | Name: "http_request_duration_seconds", 15 | Help: "Duration of HTTP requests.", 16 | Buckets: []float64{0.2, 0.5, 1, 2, 5}, 17 | }, []string{"operation"}) 18 | 19 | func init() { 20 | prometheus.MustRegister(HttpRequests, HttpDuration) 21 | } -------------------------------------------------------------------------------- /app/middlewares/auth.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/enum" 5 | "github.com/getfider/fider/app/pkg/web" 6 | ) 7 | 8 | // IsAuthenticated blocks non-authenticated requests 9 | func IsAuthenticated() web.MiddlewareFunc { 10 | return func(next web.HandlerFunc) web.HandlerFunc { 11 | return func(c *web.Context) error { 12 | if !c.IsAuthenticated() { 13 | return c.Unauthorized() 14 | } 15 | return next(c) 16 | } 17 | } 18 | } 19 | 20 | // IsAuthorized blocks non-authorized requests 21 | func IsAuthorized(roles ...enum.Role) web.MiddlewareFunc { 22 | return func(next web.HandlerFunc) web.HandlerFunc { 23 | return func(c *web.Context) error { 24 | user := c.User() 25 | for _, role := range roles { 26 | if user.Role == role { 27 | return next(c) 28 | } 29 | } 30 | return c.Forbidden() 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/middlewares/cache.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/getfider/fider/app/pkg/web" 8 | ) 9 | 10 | // ClientCache adds Cache-Control header for X seconds 11 | func ClientCache(d time.Duration) web.MiddlewareFunc { 12 | return func(next web.HandlerFunc) web.HandlerFunc { 13 | return func(c *web.Context) error { 14 | c.Response.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%.f", d.Seconds())) 15 | return next(c) 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/middlewares/chain.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/getfider/fider/app/pkg/web" 5 | ) 6 | 7 | // Chain combines multiple middlewares into one 8 | func Chain(mws ...web.MiddlewareFunc) web.MiddlewareFunc { 9 | return func(handler web.HandlerFunc) web.HandlerFunc { 10 | next := handler 11 | for i := len(mws) - 1; i >= 0; i-- { 12 | mw := mws[i] 13 | if mw != nil { 14 | next = mw(next) 15 | } 16 | } 17 | return next 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/middlewares/cors.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/getfider/fider/app/pkg/web" 5 | ) 6 | 7 | // CORS adds Cross-Origin Resource Sharing response headers 8 | func CORS() web.MiddlewareFunc { 9 | return func(next web.HandlerFunc) web.HandlerFunc { 10 | return func(c *web.Context) error { 11 | c.Response.Header().Set("Access-Control-Allow-Origin", "*") 12 | c.Response.Header().Set("Access-Control-Allow-Methods", "GET") 13 | return next(c) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/middlewares/cors_test.go: -------------------------------------------------------------------------------- 1 | package middlewares_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/getfider/fider/app/middlewares" 8 | . "github.com/getfider/fider/app/pkg/assert" 9 | "github.com/getfider/fider/app/pkg/mock" 10 | "github.com/getfider/fider/app/pkg/web" 11 | ) 12 | 13 | func TestCORS(t *testing.T) { 14 | RegisterT(t) 15 | 16 | server := mock.NewServer() 17 | server.Use(middlewares.CORS()) 18 | handler := func(c *web.Context) error { 19 | return c.NoContent(http.StatusOK) 20 | } 21 | 22 | status, response := server.Execute(handler) 23 | 24 | Expect(status).Equals(http.StatusOK) 25 | Expect(response.Header().Get("Access-Control-Allow-Origin")).Equals("*") 26 | Expect(response.Header().Get("Access-Control-Allow-Methods")).Equals("GET") 27 | } 28 | -------------------------------------------------------------------------------- /app/middlewares/locale.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/getfider/fider/app" 5 | "github.com/getfider/fider/app/pkg/web" 6 | ) 7 | 8 | // SetLocale defines given locale in context for all subsequent operations 9 | func SetLocale(locale string) web.MiddlewareFunc { 10 | return func(next web.HandlerFunc) web.HandlerFunc { 11 | return func(c *web.Context) error { 12 | c.Set(app.LocaleCtxKey, locale) 13 | return next(c) 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/middlewares/maintenance.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/getfider/fider/app/pkg/env" 7 | "github.com/getfider/fider/app/pkg/web" 8 | ) 9 | 10 | // Maintenance returns a maintenance page when system is under maintenance 11 | func Maintenance() web.MiddlewareFunc { 12 | if !env.Config.Maintenance.Enabled { 13 | return nil 14 | } 15 | 16 | return func(next web.HandlerFunc) web.HandlerFunc { 17 | return func(c *web.Context) error { 18 | 19 | c.Response.Header().Set("Retry-After", "3600") 20 | 21 | return c.Page(http.StatusServiceUnavailable, web.Props{ 22 | Page: "Error/Maintenance.page", 23 | Title: "UNDER MAINTENANCE", 24 | Description: env.Config.Maintenance.Message, 25 | Data: web.Map{ 26 | "message": env.Config.Maintenance.Message, 27 | "until": env.Config.Maintenance.Until, 28 | }, 29 | }) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /app/middlewares/panic.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "github.com/getfider/fider/app/pkg/errors" 5 | "github.com/getfider/fider/app/pkg/log" 6 | "github.com/getfider/fider/app/pkg/web" 7 | ) 8 | 9 | func CatchPanic() web.MiddlewareFunc { 10 | return func(next web.HandlerFunc) web.HandlerFunc { 11 | return func(c *web.Context) error { 12 | defer func() { 13 | if r := recover(); r != nil { 14 | err := c.Failure(errors.Panicked(r)) 15 | log.Error(c, err) 16 | c.Rollback() 17 | } 18 | }() 19 | 20 | return next(c) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/middlewares/panic_test.go: -------------------------------------------------------------------------------- 1 | package middlewares_test 2 | 3 | import ( 4 | "net/http" 5 | "testing" 6 | 7 | "github.com/getfider/fider/app/middlewares" 8 | "github.com/getfider/fider/app/pkg/mock" 9 | "github.com/getfider/fider/app/pkg/web" 10 | 11 | . "github.com/getfider/fider/app/pkg/assert" 12 | ) 13 | 14 | func TestCatchPanic_Success(t *testing.T) { 15 | RegisterT(t) 16 | 17 | server := mock.NewServer() 18 | server.Use(middlewares.CatchPanic()) 19 | status, _ := server.Execute(func(c *web.Context) error { 20 | return c.Ok(web.Map{}) 21 | }) 22 | 23 | Expect(status).Equals(http.StatusOK) 24 | } 25 | 26 | func TestCatchPanic_Panic(t *testing.T) { 27 | RegisterT(t) 28 | 29 | server := mock.NewServer() 30 | server.Use(middlewares.CatchPanic()) 31 | status, _ := server.Execute(func(c *web.Context) error { 32 | panic("Boom!") 33 | }) 34 | 35 | Expect(status).Equals(http.StatusInternalServerError) 36 | } 37 | -------------------------------------------------------------------------------- /app/middlewares/session.go: -------------------------------------------------------------------------------- 1 | package middlewares 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/getfider/fider/app/pkg/rand" 8 | "github.com/getfider/fider/app/pkg/web" 9 | ) 10 | 11 | // Session starts a new Session if an Session ID is not yet set 12 | func Session() web.MiddlewareFunc { 13 | return func(next web.HandlerFunc) web.HandlerFunc { 14 | return func(c *web.Context) error { 15 | cookie, err := c.Request.Cookie(web.CookieSessionName) 16 | if err != nil { 17 | nextYear := time.Now().Add(365 * 24 * time.Hour) 18 | cookie = c.AddCookie(web.CookieSessionName, rand.String(48), nextYear) 19 | } 20 | c.SetSessionID(cookie.Value) 21 | err = next(c) 22 | cc := c.Response.Header().Get("Cache-Control") 23 | if strings.Contains(cc, "max-age=") { 24 | c.Response.Header().Del("Set-Cookie") 25 | } 26 | return err 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/models/cmd/attachment.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/dto" 5 | "github.com/getfider/fider/app/models/entity" 6 | ) 7 | 8 | type SetAttachments struct { 9 | Post *entity.Post 10 | Comment *entity.Comment 11 | Attachments []*dto.ImageUpload 12 | } 13 | 14 | type UploadImage struct { 15 | Image *dto.ImageUpload 16 | Folder string 17 | } 18 | 19 | type UploadImages struct { 20 | Images []*dto.ImageUpload 21 | Folder string 22 | } 23 | -------------------------------------------------------------------------------- /app/models/cmd/billing.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/getfider/fider/app/models/dto" 7 | ) 8 | 9 | type GenerateCheckoutLink struct { 10 | PlanID string 11 | Passthrough dto.PaddlePassthrough 12 | 13 | // Output 14 | URL string 15 | } 16 | 17 | type ActivateBillingSubscription struct { 18 | TenantID int 19 | SubscriptionID string 20 | PlanID string 21 | } 22 | 23 | type CancelBillingSubscription struct { 24 | TenantID int 25 | SubscriptionEndsAt time.Time 26 | } 27 | 28 | type LockExpiredTenants struct { 29 | //Output 30 | NumOfTenantsLocked int64 31 | TenantsLocked []int 32 | } 33 | -------------------------------------------------------------------------------- /app/models/cmd/blob.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | type StoreBlob struct { 4 | Key string 5 | Content []byte 6 | ContentType string 7 | } 8 | 9 | type DeleteBlob struct { 10 | Key string 11 | } 12 | -------------------------------------------------------------------------------- /app/models/cmd/comment.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/entity" 5 | ) 6 | 7 | type AddNewComment struct { 8 | Post *entity.Post 9 | Content string 10 | 11 | Result *entity.Comment 12 | } 13 | 14 | type UpdateComment struct { 15 | CommentID int 16 | Content string 17 | } 18 | 19 | type DeleteComment struct { 20 | CommentID int 21 | } 22 | -------------------------------------------------------------------------------- /app/models/cmd/email.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/getfider/fider/app/models/dto" 4 | 5 | type SendMail struct { 6 | From dto.Recipient 7 | To []dto.Recipient 8 | TemplateName string 9 | Props dto.Props 10 | } 11 | -------------------------------------------------------------------------------- /app/models/cmd/event.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | type StoreEvent struct { 4 | ClientIP string 5 | EventName string 6 | } 7 | -------------------------------------------------------------------------------- /app/models/cmd/http.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | 7 | "github.com/getfider/fider/app/models/dto" 8 | ) 9 | 10 | type HTTPRequest struct { 11 | URL string 12 | Body io.Reader 13 | Method string 14 | Headers map[string]string 15 | BasicAuth *dto.BasicAuth 16 | 17 | //Output 18 | ResponseBody []byte 19 | ResponseStatusCode int 20 | ResponseHeader http.Header 21 | } 22 | -------------------------------------------------------------------------------- /app/models/cmd/log.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/getfider/fider/app/models/dto" 4 | 5 | type LogDebug struct { 6 | Message string 7 | Props dto.Props 8 | } 9 | 10 | type LogError struct { 11 | Err error 12 | Message string 13 | Props dto.Props 14 | } 15 | 16 | type LogWarn struct { 17 | Message string 18 | Props dto.Props 19 | } 20 | 21 | type LogInfo struct { 22 | Message string 23 | Props dto.Props 24 | } 25 | -------------------------------------------------------------------------------- /app/models/cmd/mention_notification.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | type AddMentionNotification struct { 4 | UserID int `db:"user_id"` 5 | CommentID int `db:"comment_id"` 6 | } 7 | -------------------------------------------------------------------------------- /app/models/cmd/notification.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/entity" 5 | ) 6 | 7 | type MarkAllNotificationsAsRead struct{} 8 | 9 | type PurgeExpiredNotifications struct { 10 | NumOfDeletedNotifications int 11 | } 12 | 13 | type MarkNotificationAsRead struct { 14 | ID int 15 | } 16 | 17 | type AddNewNotification struct { 18 | User *entity.User 19 | Title string 20 | Link string 21 | PostID int 22 | 23 | Result *entity.Notification 24 | } 25 | 26 | type AddSubscriber struct { 27 | Post *entity.Post 28 | User *entity.User 29 | } 30 | 31 | type RemoveSubscriber struct { 32 | Post *entity.Post 33 | User *entity.User 34 | } 35 | 36 | type SupressEmail struct { 37 | EmailAddresses []string 38 | 39 | //Output 40 | NumOfSupressedEmailAddresses int 41 | } 42 | -------------------------------------------------------------------------------- /app/models/cmd/oauth.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/dto" 5 | ) 6 | 7 | type SaveCustomOAuthConfig struct { 8 | ID int 9 | Logo *dto.ImageUpload 10 | Provider string 11 | Status int 12 | DisplayName string 13 | ClientID string 14 | ClientSecret string 15 | AuthorizeURL string 16 | TokenURL string 17 | Scope string 18 | ProfileURL string 19 | IsTrusted bool 20 | JSONUserIDPath string 21 | JSONUserNamePath string 22 | JSONUserEmailPath string 23 | } 24 | 25 | type ParseOAuthRawProfile struct { 26 | Provider string 27 | Body string 28 | 29 | Result *dto.OAuthUserProfile 30 | } 31 | -------------------------------------------------------------------------------- /app/models/cmd/post.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/entity" 5 | "github.com/getfider/fider/app/models/enum" 6 | ) 7 | 8 | type AddNewPost struct { 9 | Title string 10 | Description string 11 | 12 | Result *entity.Post 13 | } 14 | 15 | type UpdatePost struct { 16 | Post *entity.Post 17 | Title string 18 | Description string 19 | 20 | Result *entity.Post 21 | } 22 | 23 | type SetPostResponse struct { 24 | Post *entity.Post 25 | Text string 26 | Status enum.PostStatus 27 | } 28 | -------------------------------------------------------------------------------- /app/models/cmd/reaction.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/getfider/fider/app/models/entity" 4 | 5 | type ToggleCommentReaction struct { 6 | Comment *entity.Comment 7 | Emoji string 8 | User *entity.User 9 | Result bool 10 | } 11 | -------------------------------------------------------------------------------- /app/models/cmd/system.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | type SetSystemSettings struct { 4 | Key string 5 | Value string 6 | } 7 | -------------------------------------------------------------------------------- /app/models/cmd/tag.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/entity" 5 | ) 6 | 7 | type AddNewTag struct { 8 | Name string 9 | Color string 10 | IsPublic bool 11 | 12 | Result *entity.Tag 13 | } 14 | 15 | type UpdateTag struct { 16 | TagID int 17 | Name string 18 | Color string 19 | IsPublic bool 20 | 21 | Result *entity.Tag 22 | } 23 | 24 | type DeleteTag struct { 25 | Tag *entity.Tag 26 | } 27 | 28 | type AssignTag struct { 29 | Tag *entity.Tag 30 | Post *entity.Post 31 | } 32 | 33 | type UnassignTag struct { 34 | Tag *entity.Tag 35 | Post *entity.Post 36 | } 37 | -------------------------------------------------------------------------------- /app/models/cmd/userlist.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import "github.com/getfider/fider/app/models/enum" 4 | 5 | type UserListCreateCompany struct { 6 | Name string 7 | TenantId int 8 | SignedUpAt string 9 | BillingStatus string 10 | Subdomain string 11 | UserId int 12 | UserEmail string 13 | UserName string 14 | } 15 | 16 | type UserListUpdateCompany struct { 17 | TenantId int 18 | Name string 19 | BillingStatus enum.BillingStatus 20 | } 21 | 22 | type UserListUpdateUser struct { 23 | Id int 24 | TenantId int 25 | Email string 26 | Name string 27 | } 28 | 29 | type UserListHandleRoleChange struct { 30 | Id int 31 | Role enum.Role 32 | } 33 | -------------------------------------------------------------------------------- /app/models/cmd/vote.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/entity" 5 | ) 6 | 7 | type AddVote struct { 8 | Post *entity.Post 9 | User *entity.User 10 | } 11 | 12 | type RemoveVote struct { 13 | Post *entity.Post 14 | User *entity.User 15 | } 16 | 17 | type MarkPostAsDuplicate struct { 18 | Post *entity.Post 19 | Original *entity.Post 20 | } 21 | -------------------------------------------------------------------------------- /app/models/cmd/webhook.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/dto" 5 | "github.com/getfider/fider/app/models/enum" 6 | "github.com/getfider/fider/app/pkg/webhook" 7 | ) 8 | 9 | type TestWebhook struct { 10 | ID int 11 | 12 | Result *dto.WebhookTriggerResult 13 | } 14 | 15 | type TriggerWebhooks struct { 16 | Type enum.WebhookType 17 | Props webhook.Props 18 | } 19 | 20 | type PreviewWebhook struct { 21 | Type enum.WebhookType 22 | Url string 23 | Content string 24 | 25 | Result *dto.WebhookPreviewResult 26 | } 27 | 28 | type GetWebhookProps struct { 29 | Type enum.WebhookType 30 | 31 | Result webhook.Props 32 | } 33 | -------------------------------------------------------------------------------- /app/models/dto/billing.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type PaddlePassthrough struct { 4 | TenantID int `json:"tenant_id"` 5 | } 6 | -------------------------------------------------------------------------------- /app/models/dto/blob.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type Blob struct { 4 | Size int64 5 | Content []byte 6 | ContentType string 7 | } 8 | -------------------------------------------------------------------------------- /app/models/dto/email.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import "net/mail" 4 | 5 | // Recipient contains details of who is receiving the email 6 | type Recipient struct { 7 | Name string 8 | Address string 9 | Props Props 10 | } 11 | 12 | // NewRecipient creates a new Recipient 13 | func NewRecipient(name, address string, props Props) Recipient { 14 | return Recipient{ 15 | Name: name, 16 | Address: address, 17 | Props: props, 18 | } 19 | } 20 | 21 | // Strings returns the RFC format to send emails via SMTP 22 | func (r Recipient) String() string { 23 | if r.Address == "" { 24 | return "" 25 | } 26 | 27 | address := mail.Address{ 28 | Name: r.Name, 29 | Address: r.Address, 30 | } 31 | 32 | return address.String() 33 | } 34 | -------------------------------------------------------------------------------- /app/models/dto/http.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type BasicAuth struct { 4 | User string 5 | Password string 6 | } 7 | -------------------------------------------------------------------------------- /app/models/dto/oauth.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | //OAuthUserProfile represents an OAuth user profile 4 | type OAuthUserProfile struct { 5 | ID string `json:"id"` 6 | Name string `json:"name"` 7 | Email string `json:"email"` 8 | } 9 | 10 | //OAuthProviderOption represents an OAuth provider that can be used to authenticate 11 | type OAuthProviderOption struct { 12 | Provider string `json:"provider"` 13 | DisplayName string `json:"displayName"` 14 | ClientID string `json:"clientID"` 15 | URL string `json:"url"` 16 | CallbackURL string `json:"callbackURL"` 17 | LogoBlobKey string `json:"logoBlobKey"` 18 | IsCustomProvider bool `json:"isCustomProvider"` 19 | IsEnabled bool `json:"isEnabled"` 20 | } 21 | -------------------------------------------------------------------------------- /app/models/dto/props.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "database/sql/driver" 5 | "encoding/json" 6 | ) 7 | 8 | // Props is a map of key:value 9 | type Props map[string]any 10 | 11 | // Value converts props into a database value 12 | func (p Props) Value() (driver.Value, error) { 13 | j, err := json.Marshal(p) 14 | return j, err 15 | } 16 | 17 | // Merge current props with given props 18 | func (p Props) Merge(props Props) Props { 19 | new := Props{} 20 | for k, v := range p { 21 | new[k] = v 22 | } 23 | for k, v := range props { 24 | new[k] = v 25 | } 26 | return new 27 | } 28 | 29 | // Append add the given props to current props 30 | func (p Props) Append(props Props) { 31 | for k, v := range props { 32 | p[k] = v 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/models/dto/props_test.go: -------------------------------------------------------------------------------- 1 | package dto_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/getfider/fider/app/models/dto" 7 | 8 | . "github.com/getfider/fider/app/pkg/assert" 9 | ) 10 | 11 | func TestPropsMerge(t *testing.T) { 12 | RegisterT(t) 13 | 14 | p1 := dto.Props{ 15 | "name": "Jon", 16 | "age": 26, 17 | } 18 | p2 := p1.Merge(dto.Props{ 19 | "age": 30, 20 | "email": "john.snow@got.com", 21 | }) 22 | Expect(p2).Equals(dto.Props{ 23 | "name": "Jon", 24 | "age": 30, 25 | "email": "john.snow@got.com", 26 | }) 27 | } 28 | -------------------------------------------------------------------------------- /app/models/dto/upload.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | //ImageUpload is the input model used to upload/remove an image 4 | type ImageUpload struct { 5 | BlobKey string `json:"bkey"` 6 | Upload *ImageUploadData `json:"upload"` 7 | Remove bool `json:"remove"` 8 | } 9 | 10 | //ImageUploadData is the input model used to upload a new logo 11 | type ImageUploadData struct { 12 | FileName string `json:"fileName"` 13 | ContentType string `json:"contentType"` 14 | Content []byte `json:"content"` 15 | } 16 | -------------------------------------------------------------------------------- /app/models/dto/user.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | type UserNames struct { 4 | ID int `json:"id"` 5 | Name string `json:"name"` 6 | } 7 | -------------------------------------------------------------------------------- /app/models/dto/userlist.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import "github.com/getfider/fider/app/models/enum" 4 | 5 | type UserListUpdateCompany struct { 6 | TenantID int 7 | Name string 8 | BillingStatus enum.BillingStatus 9 | } 10 | -------------------------------------------------------------------------------- /app/models/dto/webhook.go: -------------------------------------------------------------------------------- 1 | package dto 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/entity" 5 | "github.com/getfider/fider/app/pkg/webhook" 6 | ) 7 | 8 | type WebhookTriggerResult struct { 9 | Webhook *entity.Webhook `json:"webhook"` 10 | Props webhook.Props `json:"props"` 11 | Success bool `json:"success"` 12 | Url string `json:"url"` 13 | Content string `json:"content"` 14 | StatusCode int `json:"status_code"` 15 | Message string `json:"message"` 16 | Error string `json:"error"` 17 | } 18 | 19 | type WebhookPreviewResult struct { 20 | Url PreviewedField `json:"url"` 21 | Content PreviewedField `json:"content"` 22 | } 23 | 24 | type PreviewedField struct { 25 | Value string `json:"value,omitempty"` 26 | Message string `json:"message,omitempty"` 27 | Error string `json:"error,omitempty"` 28 | } 29 | -------------------------------------------------------------------------------- /app/models/entity/comment.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type ReactionCounts struct { 8 | Emoji string `json:"emoji"` 9 | Count int `json:"count"` 10 | IncludesMe bool `json:"includesMe"` 11 | } 12 | 13 | // Comment represents an user comment on an post 14 | type Comment struct { 15 | ID int `json:"id"` 16 | Content string `json:"content"` 17 | CreatedAt time.Time `json:"createdAt"` 18 | User *User `json:"user"` 19 | Attachments []string `json:"attachments,omitempty"` 20 | EditedAt *time.Time `json:"editedAt,omitempty"` 21 | EditedBy *User `json:"editedBy,omitempty"` 22 | ReactionCounts []ReactionCounts `json:"reactionCounts,omitempty"` 23 | } 24 | -------------------------------------------------------------------------------- /app/models/entity/email_verification.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/getfider/fider/app/models/enum" 7 | "github.com/getfider/fider/app/pkg/rand" 8 | ) 9 | 10 | //EmailVerification is the model used by email verification process 11 | type EmailVerification struct { 12 | Email string 13 | Name string 14 | Key string 15 | UserID int 16 | Kind enum.EmailVerificationKind 17 | CreatedAt time.Time 18 | ExpiresAt time.Time 19 | VerifiedAt *time.Time 20 | } 21 | 22 | // GenerateEmailVerificationKey returns a 64 chars key 23 | func GenerateEmailVerificationKey() string { 24 | return rand.String(64) 25 | } 26 | -------------------------------------------------------------------------------- /app/models/entity/mention.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | type CommentString string 8 | 9 | const mentionRegex = `@\[(.*?)\]` 10 | 11 | func (commentString CommentString) ParseMentions() []string { 12 | r, _ := regexp.Compile(mentionRegex) 13 | matches := r.FindAllStringSubmatch(string(commentString), -1) 14 | 15 | mentions := []string{} 16 | 17 | for _, match := range matches { 18 | if len(match) >= 2 && match[1] != "" { 19 | mentions = append(mentions, match[1]) 20 | } 21 | } 22 | 23 | return mentions 24 | } 25 | 26 | func (commentString CommentString) SanitizeMentions() string { 27 | r, _ := regexp.Compile(mentionRegex) 28 | return r.ReplaceAllString(string(commentString), "@$1") 29 | } 30 | -------------------------------------------------------------------------------- /app/models/entity/mention_notification.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // NotificationLog represents a record of a notification that was sent to a user 8 | type MentionNotification struct { 9 | ID int `json:"id" db:"id"` 10 | TenantID int `json:"-" db:"tenant_id"` 11 | UserID int `json:"userId" db:"user_id"` 12 | CommentID int `json:"commentId,omitempty" db:"comment_id"` 13 | CreatedAt time.Time `json:"createdAt" db:"created_on"` 14 | } 15 | -------------------------------------------------------------------------------- /app/models/entity/notification.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/getfider/fider/app/models/enum" 7 | ) 8 | 9 | // Notification is the system generated notification entity 10 | type Notification struct { 11 | ID int `json:"id" db:"id"` 12 | Title string `json:"title" db:"title"` 13 | Link string `json:"link" db:"link"` 14 | Read bool `json:"read" db:"read"` 15 | CreatedAt time.Time `json:"createdAt" db:"created_at"` 16 | AuthorName string `json:"authorName" db:"name"` 17 | AuthorID int `json:"-" db:"author_id"` 18 | AvatarBlobKey string `json:"-" db:"avatar_bkey"` 19 | AvatarType enum.AvatarType `json:"-" db:"avatar_type"` 20 | AvatarURL string `json:"avatarURL,omitempty"` 21 | } 22 | -------------------------------------------------------------------------------- /app/models/entity/reaction.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import "time" 4 | 5 | // Reaction represents a user's emoji reaction to a comment 6 | type Reaction struct { 7 | ID int `json:"id"` 8 | Emoji string `json:"emoji"` 9 | Comment *Comment `json:"-"` 10 | User *User `json:"user"` 11 | CreatedAt time.Time `json:"createdAt"` 12 | } 13 | -------------------------------------------------------------------------------- /app/models/entity/tag.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | //Tag represents a simple tag 4 | type Tag struct { 5 | ID int `json:"id"` 6 | Name string `json:"name"` 7 | Slug string `json:"slug"` 8 | Color string `json:"color"` 9 | IsPublic bool `json:"isPublic"` 10 | } 11 | -------------------------------------------------------------------------------- /app/models/entity/vote.go: -------------------------------------------------------------------------------- 1 | package entity 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | //VoteUser represents a user that voted on a post 8 | type VoteUser struct { 9 | ID int `json:"id"` 10 | Name string `json:"name"` 11 | Email string `json:"email,omitempty"` 12 | AvatarURL string `json:"avatarURL,omitempty"` 13 | } 14 | 15 | //Vote represents a vote given by a user on a post 16 | type Vote struct { 17 | User *VoteUser `json:"user"` 18 | CreatedAt time.Time `json:"createdAt"` 19 | } 20 | -------------------------------------------------------------------------------- /app/models/enum/email_kind.go: -------------------------------------------------------------------------------- 1 | package enum 2 | 3 | //EmailVerificationKind specifies which kind of process is being verified by email 4 | type EmailVerificationKind int16 5 | 6 | const ( 7 | //EmailVerificationKindSignIn is the sign in by email process 8 | EmailVerificationKindSignIn EmailVerificationKind = 1 9 | //EmailVerificationKindSignUp is the sign up (create tenant) by name and email process 10 | EmailVerificationKindSignUp EmailVerificationKind = 2 11 | //EmailVerificationKindChangeEmail is the change user email process 12 | EmailVerificationKindChangeEmail EmailVerificationKind = 3 13 | //EmailVerificationKindUserInvitation is the sign in invitation sent to an user 14 | EmailVerificationKindUserInvitation EmailVerificationKind = 4 15 | ) 16 | -------------------------------------------------------------------------------- /app/models/enum/oauth.go: -------------------------------------------------------------------------------- 1 | package enum 2 | 3 | var ( 4 | //OAuthConfigDisabled is used to disable an OAuthConfig for signin 5 | OAuthConfigDisabled = 1 6 | //OAuthConfigEnabled is used to enable an OAuthConfig for public use 7 | OAuthConfigEnabled = 2 8 | ) 9 | -------------------------------------------------------------------------------- /app/models/enum/subscriber.go: -------------------------------------------------------------------------------- 1 | package enum 2 | 3 | var ( 4 | //SubscriberInactive means that the user cancelled the subscription 5 | SubscriberInactive = 0 6 | //SubscriberActive means that the subscription is active 7 | SubscriberActive = 1 8 | ) 9 | -------------------------------------------------------------------------------- /app/models/enum/tenant_status.go: -------------------------------------------------------------------------------- 1 | package enum 2 | 3 | // TenantStatus is the status of a tenant 4 | type TenantStatus int 5 | 6 | var ( 7 | //TenantActive is the default status for most tenants 8 | TenantActive TenantStatus = 1 9 | //TenantPending is used for signup via email that requires user confirmation 10 | TenantPending TenantStatus = 2 11 | //TenantLocked is used to set tenant on a read-only mode 12 | TenantLocked TenantStatus = 3 13 | //TenantDisabled is used to block all access 14 | TenantDisabled TenantStatus = 4 15 | ) 16 | 17 | var tenantStatusIDs = map[TenantStatus]string{ 18 | TenantActive: "active", 19 | TenantPending: "pending", 20 | TenantLocked: "locked", 21 | TenantDisabled: "disabled", 22 | } 23 | 24 | // String returns the string version of the tenant status 25 | func (status TenantStatus) String() string { 26 | return tenantStatusIDs[status] 27 | } 28 | -------------------------------------------------------------------------------- /app/models/query/attachment.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/entity" 5 | ) 6 | 7 | type GetAttachments struct { 8 | Post *entity.Post 9 | Comment *entity.Comment 10 | 11 | Result []string 12 | } 13 | -------------------------------------------------------------------------------- /app/models/query/billing.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/entity" 5 | ) 6 | 7 | type GetBillingState struct { 8 | // Output 9 | Result *entity.BillingState 10 | } 11 | 12 | type GetBillingSubscription struct { 13 | SubscriptionID string 14 | 15 | // Output 16 | Result *entity.BillingSubscription 17 | } 18 | -------------------------------------------------------------------------------- /app/models/query/blob.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import "github.com/getfider/fider/app/models/dto" 4 | 5 | type ListBlobs struct { 6 | Prefix string 7 | 8 | Result []string 9 | } 10 | 11 | type GetBlobByKey struct { 12 | Key string 13 | 14 | Result *dto.Blob 15 | } 16 | -------------------------------------------------------------------------------- /app/models/query/comment.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/entity" 5 | ) 6 | 7 | type GetCommentByID struct { 8 | CommentID int 9 | 10 | Result *entity.Comment 11 | } 12 | 13 | type GetCommentsByPost struct { 14 | Post *entity.Post 15 | 16 | Result []*entity.Comment 17 | } 18 | -------------------------------------------------------------------------------- /app/models/query/email.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import "time" 4 | 5 | type FetchRecentSupressions struct { 6 | StartTime time.Time 7 | 8 | //Output 9 | EmailAddresses []string 10 | } 11 | -------------------------------------------------------------------------------- /app/models/query/notification.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/entity" 5 | "github.com/getfider/fider/app/models/enum" 6 | ) 7 | 8 | type CountUnreadNotifications struct { 9 | Result int 10 | } 11 | 12 | type GetNotificationByID struct { 13 | ID int 14 | Result *entity.Notification 15 | } 16 | 17 | type GetActiveNotifications struct { 18 | Result []*entity.Notification 19 | } 20 | 21 | type GetActiveSubscribers struct { 22 | Number int 23 | Channel enum.NotificationChannel 24 | Event enum.NotificationEvent 25 | 26 | Result []*entity.User 27 | } 28 | 29 | type GetMentionNotifications struct { 30 | CommentID int 31 | 32 | Result []*entity.MentionNotification 33 | } 34 | -------------------------------------------------------------------------------- /app/models/query/oauth.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/dto" 5 | "github.com/getfider/fider/app/models/entity" 6 | ) 7 | 8 | type GetCustomOAuthConfigByProvider struct { 9 | Provider string 10 | 11 | Result *entity.OAuthConfig 12 | } 13 | 14 | type ListCustomOAuthConfig struct { 15 | Result []*entity.OAuthConfig 16 | } 17 | 18 | type GetOAuthAuthorizationURL struct { 19 | Provider string 20 | Redirect string 21 | Identifier string 22 | 23 | Result string 24 | } 25 | 26 | type GetOAuthProfile struct { 27 | Provider string 28 | Code string 29 | 30 | Result *dto.OAuthUserProfile 31 | } 32 | 33 | type GetOAuthRawProfile struct { 34 | Provider string 35 | Code string 36 | 37 | Result string 38 | } 39 | 40 | type ListActiveOAuthProviders struct { 41 | Result []*dto.OAuthProviderOption 42 | } 43 | 44 | type ListAllOAuthProviders struct { 45 | Result []*dto.OAuthProviderOption 46 | } 47 | -------------------------------------------------------------------------------- /app/models/query/system.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | type GetSystemSettings struct { 4 | Key string 5 | 6 | // Output 7 | Value string 8 | } 9 | -------------------------------------------------------------------------------- /app/models/query/tag.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/entity" 5 | ) 6 | 7 | type GetTagBySlug struct { 8 | Slug string 9 | 10 | Result *entity.Tag 11 | } 12 | 13 | type GetAssignedTags struct { 14 | Post *entity.Post 15 | 16 | Result []*entity.Tag 17 | } 18 | 19 | type GetAllTags struct { 20 | Result []*entity.Tag 21 | } 22 | -------------------------------------------------------------------------------- /app/models/query/tenant.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/getfider/fider/app/models/entity" 7 | "github.com/getfider/fider/app/models/enum" 8 | ) 9 | 10 | type IsCNAMEAvailable struct { 11 | CNAME string 12 | 13 | // Output 14 | Result bool 15 | } 16 | 17 | type IsSubdomainAvailable struct { 18 | Subdomain string 19 | 20 | // Output 21 | Result bool 22 | } 23 | 24 | type GetVerificationByKey struct { 25 | Kind enum.EmailVerificationKind 26 | Key string 27 | 28 | // Output 29 | Result *entity.EmailVerification 30 | } 31 | 32 | type GetFirstTenant struct { 33 | 34 | // Output 35 | Result *entity.Tenant 36 | } 37 | 38 | type GetTenantByDomain struct { 39 | Domain string 40 | 41 | // Output 42 | Result *entity.Tenant 43 | } 44 | 45 | type GetTrialingTenantContacts struct { 46 | TrialExpiresOn time.Time 47 | 48 | // Output 49 | Contacts []*entity.User 50 | } 51 | -------------------------------------------------------------------------------- /app/models/query/user.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/dto" 5 | "github.com/getfider/fider/app/models/entity" 6 | ) 7 | 8 | type CountUsers struct { 9 | Result int 10 | } 11 | 12 | type UserSubscribedTo struct { 13 | PostID int 14 | 15 | Result bool 16 | } 17 | 18 | type GetUserByAPIKey struct { 19 | APIKey string 20 | 21 | Result *entity.User 22 | } 23 | 24 | type GetCurrentUserSettings struct { 25 | Result map[string]string 26 | } 27 | 28 | type GetUserByID struct { 29 | UserID int 30 | 31 | Result *entity.User 32 | } 33 | 34 | type GetUserByEmail struct { 35 | Email string 36 | 37 | Result *entity.User 38 | } 39 | 40 | type GetUserByProvider struct { 41 | Provider string 42 | UID string 43 | 44 | Result *entity.User 45 | } 46 | 47 | type GetAllUsers struct { 48 | Result []*entity.User 49 | } 50 | 51 | type GetAllUsersNames struct { 52 | Result []*dto.UserNames 53 | } 54 | -------------------------------------------------------------------------------- /app/models/query/vote.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import "github.com/getfider/fider/app/models/entity" 4 | 5 | type ListPostVotes struct { 6 | PostID int 7 | Limit int 8 | IncludeEmail bool 9 | 10 | Result []*entity.Vote 11 | } 12 | -------------------------------------------------------------------------------- /app/models/query/webhook.go: -------------------------------------------------------------------------------- 1 | package query 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/entity" 5 | "github.com/getfider/fider/app/models/enum" 6 | ) 7 | 8 | type GetWebhook struct { 9 | ID int 10 | 11 | Result *entity.Webhook 12 | } 13 | 14 | type ListAllWebhooks struct { 15 | Result []*entity.Webhook 16 | } 17 | 18 | type ListAllWebhooksByType struct { 19 | Type string 20 | 21 | Result []*entity.Webhook 22 | } 23 | 24 | type ListActiveWebhooksByType struct { 25 | Type enum.WebhookType 26 | 27 | Result []*entity.Webhook 28 | } 29 | 30 | type CreateEditWebhook struct { 31 | ID int 32 | Name string 33 | Type enum.WebhookType 34 | Status enum.WebhookStatus 35 | Url string 36 | Content string 37 | HttpMethod string 38 | HttpHeaders entity.HttpHeaders 39 | 40 | Result int 41 | } 42 | 43 | type DeleteWebhook struct { 44 | ID int 45 | } 46 | 47 | type MarkWebhookAsFailed struct { 48 | ID int 49 | } 50 | -------------------------------------------------------------------------------- /app/pkg/crypto/md5.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | ) 7 | 8 | //MD5 returns the MD5 hash of a given string 9 | func MD5(input string) string { 10 | return fmt.Sprintf("%x", md5.Sum([]byte(input))) 11 | } 12 | -------------------------------------------------------------------------------- /app/pkg/crypto/md5_test.go: -------------------------------------------------------------------------------- 1 | package crypto_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/getfider/fider/app/pkg/assert" 7 | "github.com/getfider/fider/app/pkg/crypto" 8 | ) 9 | 10 | func TestMD5Hash(t *testing.T) { 11 | RegisterT(t) 12 | 13 | hash := crypto.MD5("Fider") 14 | 15 | Expect(hash).Equals("3734538c8b650e4f354a55a436566bb6") 16 | } 17 | -------------------------------------------------------------------------------- /app/pkg/crypto/sha512.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/sha512" 5 | 6 | "fmt" 7 | ) 8 | 9 | //SHA512 returns the SHA512 hash of a given string 10 | func SHA512(input string) string { 11 | return fmt.Sprintf("%x", sha512.Sum512([]byte(input))) 12 | } 13 | -------------------------------------------------------------------------------- /app/pkg/crypto/sha512_test.go: -------------------------------------------------------------------------------- 1 | package crypto_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/getfider/fider/app/pkg/assert" 7 | "github.com/getfider/fider/app/pkg/crypto" 8 | ) 9 | 10 | func TestSHA512Hash(t *testing.T) { 11 | RegisterT(t) 12 | 13 | hash := crypto.SHA512("Fider") 14 | 15 | Expect(hash).Equals("262d21f30715f2b226264844c4ab2a934a4c0241321f77bebbca191e172df93da71c939c56fcb4bbdd8895fa8c496882d38e3ce66d9d4e3dee5bacde01e73988") 16 | } 17 | -------------------------------------------------------------------------------- /app/pkg/csv/testdata/empty.csv: -------------------------------------------------------------------------------- 1 | number,title,description,created_at,created_by,votes_count,comments_count,status,responded_by,responded_at,response,original_number,original_title,tags 2 | -------------------------------------------------------------------------------- /app/pkg/csv/testdata/more-posts.csv: -------------------------------------------------------------------------------- 1 | number,title,description,created_at,created_by,votes_count,comments_count,status,responded_by,responded_at,response,original_number,original_title,tags 2 | 10,Go is fast,Very tiny description,2018-03-23T19:33:22Z,Faceless,4,2,declined,John Snow,2018-04-04T19:48:10Z,Nothing we need to do,,,"easy, ignored" 3 | 15,Go is great,,2018-02-21T15:51:35Z,Someone else,4,2,open,,,,,, 4 | 20,Go is easy,,2018-01-12T01:46:59Z,Faceless,4,2,duplicate,Arya Stark,2018-03-17T10:15:42Z,This has already been suggested,99,Go is very easy,"this-tag-has,comma" 5 | -------------------------------------------------------------------------------- /app/pkg/csv/testdata/one-post.csv: -------------------------------------------------------------------------------- 1 | number,title,description,created_at,created_by,votes_count,comments_count,status,responded_by,responded_at,response,original_number,original_title,tags 2 | 10,Go is fast,Very tiny description,2018-03-23T19:33:22Z,Faceless,4,2,declined,John Snow,2018-04-04T19:48:10Z,Nothing we need to do,,,"easy, ignored" 3 | -------------------------------------------------------------------------------- /app/pkg/dbx/lock.go: -------------------------------------------------------------------------------- 1 | package dbx 2 | 3 | import ( 4 | "context" 5 | "hash/fnv" 6 | 7 | "github.com/getfider/fider/app/pkg/errors" 8 | "github.com/getfider/fider/app/pkg/log" 9 | ) 10 | 11 | func hash(s string) uint32 { 12 | h := fnv.New32a() 13 | h.Write([]byte(s)) 14 | return h.Sum32() 15 | } 16 | 17 | // Try to obtain an advisory lock 18 | // returns true and an unlock function if lock was aquired 19 | func TryLock(ctx context.Context, trx *Trx, key string) (bool, func()) { 20 | var locked bool 21 | if err := trx.Scalar(&locked, "SELECT pg_try_advisory_xact_lock($1)", hash(key)); err != nil { 22 | log.Error(ctx, errors.Wrap(err, "failed to acquire advisory lock")) 23 | return false, nil 24 | } 25 | 26 | unlock := func() { 27 | trx.MustCommit() 28 | } 29 | 30 | return locked, unlock 31 | } 32 | -------------------------------------------------------------------------------- /app/pkg/dbx/testdata/migration_failure/210001010000_create_err.sql: -------------------------------------------------------------------------------- 1 | create table foo; -------------------------------------------------------------------------------- /app/pkg/dbx/testdata/migration_failure/210001010001_create_ok.sql: -------------------------------------------------------------------------------- 1 | create table if not exists dummy ( 2 | id int not null, 3 | description varchar(200) not null 4 | ); -------------------------------------------------------------------------------- /app/pkg/dbx/testdata/migration_success/210001010000_create.sql: -------------------------------------------------------------------------------- 1 | create table dummy ( 2 | id int not null, 3 | description varchar(200) not null 4 | ); 5 | 6 | insert into dummy (id, description) values (100, 'Description 100X'); 7 | insert into dummy (id, description) values (200, 'Description 200Y'); 8 | insert into dummy (id, description) values (300, 'Description 300Z'); -------------------------------------------------------------------------------- /app/pkg/dbx/testdata/migration_success/210001010001_delete.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM dummy WHERE id = 300; -------------------------------------------------------------------------------- /app/pkg/dbx/types.go: -------------------------------------------------------------------------------- 1 | package dbx 2 | 3 | import ( 4 | "database/sql" 5 | "encoding/json" 6 | ) 7 | 8 | // NullInt representa a nullable integer 9 | type NullInt struct { 10 | sql.NullInt64 11 | } 12 | 13 | // MarshalJSON interface redefinition 14 | func (r NullInt) MarshalJSON() ([]byte, error) { 15 | if r.Valid { 16 | return json.Marshal(r.Int64) 17 | } 18 | return json.Marshal(nil) 19 | } 20 | 21 | // NullString representa a nullable string 22 | type NullString struct { 23 | sql.NullString 24 | } 25 | 26 | // MarshalJSON interface redefinition 27 | func (r NullString) MarshalJSON() ([]byte, error) { 28 | if r.Valid { 29 | return json.Marshal(r.String) 30 | } 31 | return json.Marshal(nil) 32 | } 33 | 34 | // NullTime representa a nullable time.Time 35 | type NullTime struct { 36 | sql.NullTime 37 | } 38 | 39 | // MarshalJSON interface redefinition 40 | func (r NullTime) MarshalJSON() ([]byte, error) { 41 | if r.Valid { 42 | return json.Marshal(r.Time) 43 | } 44 | return json.Marshal(nil) 45 | } 46 | -------------------------------------------------------------------------------- /app/pkg/errors/path.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "runtime" 5 | "strings" 6 | ) 7 | 8 | var ( 9 | prefixSize int 10 | basePath string 11 | ) 12 | 13 | func init() { 14 | _, file, _, ok := runtime.Caller(0) 15 | if file == "?" { 16 | return 17 | } 18 | if ok { 19 | size := len(file) 20 | suffix := len("app/pkg/errors/path.go") 21 | basePath = file[:size-suffix] 22 | prefixSize = len(basePath) 23 | } 24 | } 25 | 26 | func trimBasePath(filename string) string { 27 | if strings.HasPrefix(filename, basePath) { 28 | return filename[prefixSize:] 29 | } 30 | return filename 31 | } 32 | -------------------------------------------------------------------------------- /app/pkg/log/level.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | // Level defines all possible log levels 4 | type Level uint8 5 | 6 | const ( 7 | // DEBUG for verbose logs 8 | DEBUG Level = iota + 1 9 | // INFO for WARN+ERROR+INFO logs 10 | INFO 11 | // WARN for WARN+ERROR logs 12 | WARN 13 | // ERROR for ERROR only logs 14 | ERROR 15 | // NONE is used to disable logs 16 | NONE 17 | ) 18 | 19 | // parseLevel returns a log.Level based on input string 20 | func parseLevel(level string) Level { 21 | switch level { 22 | case "DEBUG": 23 | return DEBUG 24 | case "WARN": 25 | return WARN 26 | case "ERROR": 27 | return ERROR 28 | default: 29 | return INFO 30 | } 31 | } 32 | 33 | func (l Level) String() string { 34 | switch l { 35 | case DEBUG: 36 | return "DEBUG" 37 | case INFO: 38 | return "INFO" 39 | case WARN: 40 | return "WARN" 41 | case ERROR: 42 | return "ERROR" 43 | case NONE: 44 | return "NONE" 45 | default: 46 | return "???" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/pkg/rand/random.go: -------------------------------------------------------------------------------- 1 | package rand 2 | 3 | import ( 4 | "crypto/rand" 5 | "math/big" 6 | ) 7 | 8 | var chars = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") 9 | 10 | // String returns a random string of given length 11 | func String(n int) string { 12 | if n <= 0 { 13 | return "" 14 | } 15 | 16 | bytes := make([]byte, n) 17 | charsetLen := big.NewInt(int64(len(chars))) 18 | for i := 0; i < n; i++ { 19 | c, err := rand.Int(rand.Reader, charsetLen) 20 | if err != nil { 21 | panic(err) 22 | } 23 | bytes[i] = chars[c.Int64()] 24 | } 25 | 26 | return string(bytes) 27 | } 28 | -------------------------------------------------------------------------------- /app/pkg/rand/random_test.go: -------------------------------------------------------------------------------- 1 | package rand_test 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/getfider/fider/app/pkg/assert" 7 | "github.com/getfider/fider/app/pkg/rand" 8 | ) 9 | 10 | func TestRandomString(t *testing.T) { 11 | RegisterT(t) 12 | 13 | Expect(rand.String(10000)).HasLen(10000) 14 | Expect(rand.String(10)).HasLen(10) 15 | Expect(rand.String(0)).HasLen(0) 16 | Expect(rand.String(-1)).HasLen(0) 17 | } 18 | -------------------------------------------------------------------------------- /app/pkg/tpl/template_test.go: -------------------------------------------------------------------------------- 1 | package tpl_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "testing" 7 | 8 | "github.com/getfider/fider/app/models/dto" 9 | . "github.com/getfider/fider/app/pkg/assert" 10 | "github.com/getfider/fider/app/pkg/tpl" 11 | ) 12 | 13 | func TestGetTemplate_Render(t *testing.T) { 14 | RegisterT(t) 15 | 16 | bf := new(bytes.Buffer) 17 | tmpl := tpl.GetTemplate("app/pkg/tpl/testdata/base.html", "app/pkg/tpl/testdata/echo.html") 18 | err := tpl.Render(context.Background(), tmpl, bf, dto.Props{ 19 | "name": "John", 20 | }) 21 | 22 | Expect(err).IsNil() 23 | Expect(bf.String()).Equals(` 24 | This goes on the head. 25 | 26 | Hello, John! 27 | 28 | `) 29 | } 30 | -------------------------------------------------------------------------------- /app/pkg/tpl/testdata/base.html: -------------------------------------------------------------------------------- 1 | 2 | {{block "head" .}}{{end}} 3 | {{block "body" .}}{{end}} 4 | -------------------------------------------------------------------------------- /app/pkg/tpl/testdata/echo.html: -------------------------------------------------------------------------------- 1 | {{define "head"}}This goes on the head.{{end}} 2 | 3 | {{define "body"}} 4 | {{ translate "email.greetings_name" (dict "name" .name) | html }} 5 | {{end}} -------------------------------------------------------------------------------- /app/pkg/tpl/text.go: -------------------------------------------------------------------------------- 1 | package tpl 2 | 3 | import ( 4 | "strings" 5 | "text/template" 6 | 7 | "github.com/getfider/fider/app/pkg/markdown" 8 | ) 9 | 10 | func GetTextTemplate(name string, rawText string) (*template.Template, error) { 11 | tpl, err := template.New(name).Funcs(templateFunctions).Funcs(template.FuncMap{ 12 | "markdown": func(input string) string { 13 | return markdown.PlainText(input) 14 | }, 15 | }).Parse(rawText) 16 | if err != nil { 17 | return nil, err 18 | } 19 | 20 | return tpl, nil 21 | } 22 | 23 | func Execute(tmpl *template.Template, data any) (string, error) { 24 | builder := &strings.Builder{} 25 | if err := tmpl.Execute(builder, data); err != nil { 26 | return "", err 27 | } 28 | return builder.String(), nil 29 | } 30 | -------------------------------------------------------------------------------- /app/pkg/web/metrics.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/getfider/fider/app/pkg/env" 7 | "github.com/julienschmidt/httprouter" 8 | "github.com/prometheus/client_golang/prometheus/promhttp" 9 | ) 10 | 11 | func newMetricsServer(address string) *http.Server { 12 | mux := httprouter.New() 13 | mux.Handler("GET", "/metrics", promhttp.Handler()) 14 | 15 | return &http.Server{ 16 | ReadTimeout: env.Config.HTTP.ReadTimeout, 17 | WriteTimeout: env.Config.HTTP.WriteTimeout, 18 | IdleTimeout: env.Config.HTTP.IdleTimeout, 19 | Addr: address, 20 | Handler: mux, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /app/pkg/web/response.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "bufio" 5 | "net" 6 | "net/http" 7 | ) 8 | 9 | // Response is a wrapper of http.ResponseWriter with access to the StatusCode 10 | type Response struct { 11 | Writer http.ResponseWriter 12 | StatusCode int 13 | } 14 | 15 | func (r *Response) Header() http.Header { 16 | return r.Writer.Header() 17 | } 18 | 19 | func (r *Response) WriteHeader(code int) { 20 | r.StatusCode = code 21 | r.Writer.WriteHeader(code) 22 | } 23 | 24 | func (r *Response) Write(b []byte) (int, error) { 25 | if r.StatusCode == 0 { 26 | r.WriteHeader(http.StatusOK) 27 | } 28 | 29 | return r.Writer.Write(b) 30 | } 31 | 32 | func (r Response) Flush() { 33 | r.Writer.(http.Flusher).Flush() 34 | } 35 | 36 | func (r Response) Hijack() (net.Conn, *bufio.ReadWriter, error) { 37 | return r.Writer.(http.Hijacker).Hijack() 38 | } 39 | -------------------------------------------------------------------------------- /app/pkg/web/testdata/assets.json: -------------------------------------------------------------------------------- 1 | { 2 | "entrypoints": { 3 | "main": { 4 | "assets": [ 5 | { "name": "css/file1.css", "size": 100 }, 6 | { "name": "css/file1.css.map", "size": 100 }, 7 | { "name": "js/file1.js", "size": 100 }, 8 | { "name": "js/file1.js.map", "size": 100 }, 9 | { "name": "images/logo.jpg", "size": 100 }, 10 | { "name": "js/file2.js", "size": 100 }, 11 | { "name": "js/file2.js.map", "size": 100 } 12 | ] 13 | } 14 | }, 15 | "namedChunkGroups": { 16 | "Test.page": { 17 | "assets": [ 18 | { "name": "css/Test.page.css", "size": 100 }, 19 | { "name": "css/Test.page.css.map", "size": 100 }, 20 | { "name": "js/Test.page.js", "size": 100 }, 21 | { "name": "js/Test.page.js.map", "size": 100 } 22 | ] 23 | }, 24 | "locale-en-client-json": { 25 | "assets": [{ "name": "locale-en.js", "size": 100 }] 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/pkg/web/testdata/empty.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/app/pkg/web/testdata/empty.js -------------------------------------------------------------------------------- /app/pkg/web/testdata/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/app/pkg/web/testdata/favicon.ico -------------------------------------------------------------------------------- /app/pkg/web/testdata/logo1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/app/pkg/web/testdata/logo1.png -------------------------------------------------------------------------------- /app/pkg/web/testdata/logo2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/app/pkg/web/testdata/logo2.jpg -------------------------------------------------------------------------------- /app/pkg/web/testdata/logo3-200w.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/app/pkg/web/testdata/logo3-200w.gif -------------------------------------------------------------------------------- /app/pkg/web/testdata/logo3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/app/pkg/web/testdata/logo3.gif -------------------------------------------------------------------------------- /app/pkg/web/testdata/logo4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/app/pkg/web/testdata/logo4.png -------------------------------------------------------------------------------- /app/pkg/web/testdata/logo5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/app/pkg/web/testdata/logo5.png -------------------------------------------------------------------------------- /app/services/blob/testdata/file.txt: -------------------------------------------------------------------------------- 1 | This is just a sample file! -------------------------------------------------------------------------------- /app/services/blob/testdata/file2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/app/services/blob/testdata/file2.png -------------------------------------------------------------------------------- /app/services/blob/testdata/file3.txt: -------------------------------------------------------------------------------- 1 | Hello World -------------------------------------------------------------------------------- /app/services/email/smtp/auth_login.go: -------------------------------------------------------------------------------- 1 | package smtp 2 | 3 | import ( 4 | gosmtp "net/smtp" 5 | ) 6 | 7 | type loginAuth struct { 8 | username, password string 9 | } 10 | 11 | // LoginAuth returns an Auth that implements the LOGIN authentication 12 | // mechanism as defined in Internet-Draft draft-murchison-sasl-login-00. 13 | // The LOGIN mechanism is still used by some SMTP server. 14 | func LoginAuth(username, password string) gosmtp.Auth { 15 | return &loginAuth{username, password} 16 | } 17 | 18 | func (a *loginAuth) Start(server *gosmtp.ServerInfo) (string, []byte, error) { 19 | return "LOGIN", []byte(a.username), nil 20 | } 21 | 22 | func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { 23 | if more { 24 | switch string(fromServer) { 25 | case "Username:": 26 | return []byte(a.username), nil 27 | case "Password:": 28 | return []byte(a.password), nil 29 | } 30 | } 31 | return nil, nil 32 | } 33 | -------------------------------------------------------------------------------- /app/services/oauth/custom.go: -------------------------------------------------------------------------------- 1 | package oauth 2 | 3 | import "net/url" 4 | 5 | var providerParams = map[string]map[string]string{ 6 | "id.twitch.tv": { 7 | "claims": `{"userinfo":{"preferred_username":null,"email":null,"email_verified":null}}`, 8 | }, 9 | } 10 | 11 | func getProviderInitialParams(u *url.URL) url.Values { 12 | v := url.Values{} 13 | if params, ok := providerParams[u.Hostname()]; ok { 14 | for key, value := range params { 15 | v.Add(key, value) 16 | } 17 | } 18 | return v 19 | } 20 | -------------------------------------------------------------------------------- /app/services/sqlstore/postgres/billing_test.go: -------------------------------------------------------------------------------- 1 | package postgres_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/getfider/fider/app/models/cmd" 7 | 8 | . "github.com/getfider/fider/app/pkg/assert" 9 | "github.com/getfider/fider/app/pkg/bus" 10 | ) 11 | 12 | func TestLockExpiredTenants_ShouldTriggerForOneTenant(t *testing.T) { 13 | ctx := SetupDatabaseTest(t) 14 | defer TeardownDatabaseTest() 15 | 16 | // There is a tenant with an expired trial setup in the seed for the test database. 17 | q := &cmd.LockExpiredTenants{} 18 | 19 | err := bus.Dispatch(ctx, q) 20 | Expect(err).IsNil() 21 | Expect(q.NumOfTenantsLocked).Equals(int64(1)) 22 | Expect(q.TenantsLocked).Equals([]int{3}) 23 | 24 | } 25 | -------------------------------------------------------------------------------- /app/services/sqlstore/postgres/event.go: -------------------------------------------------------------------------------- 1 | package postgres 2 | 3 | import ( 4 | "context" 5 | "database/sql" 6 | "time" 7 | 8 | "github.com/getfider/fider/app/models/cmd" 9 | "github.com/getfider/fider/app/models/entity" 10 | "github.com/getfider/fider/app/pkg/dbx" 11 | "github.com/getfider/fider/app/pkg/errors" 12 | ) 13 | 14 | func storeEvent(ctx context.Context, c *cmd.StoreEvent) error { 15 | return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { 16 | dbClientIP := sql.NullString{ 17 | String: c.ClientIP, 18 | Valid: len(c.ClientIP) > 0, 19 | } 20 | 21 | _, err := trx.Execute(` 22 | INSERT INTO events (tenant_id, client_ip, name, created_at) 23 | VALUES ($1, $2, $3, $4) 24 | RETURNING id 25 | `, tenant.ID, dbClientIP, c.EventName, time.Now()) 26 | if err != nil { 27 | return errors.Wrap(err, "failed to insert event") 28 | } 29 | return nil 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /app/tasks/signin.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "github.com/getfider/fider/app/models/cmd" 5 | "github.com/getfider/fider/app/models/dto" 6 | "github.com/getfider/fider/app/pkg/bus" 7 | "github.com/getfider/fider/app/pkg/web" 8 | "github.com/getfider/fider/app/pkg/worker" 9 | ) 10 | 11 | //SendSignInEmail is used to send the sign in email to requestor 12 | func SendSignInEmail(email, verificationKey string) worker.Task { 13 | return describe("Send sign in email", func(c *worker.Context) error { 14 | to := dto.NewRecipient("", email, dto.Props{ 15 | "siteName": c.Tenant().Name, 16 | "link": link(web.BaseURL(c), "/signin/verify?k=%s", verificationKey), 17 | }) 18 | 19 | bus.Publish(c, &cmd.SendMail{ 20 | From: dto.Recipient{Name: c.Tenant().Name}, 21 | To: []dto.Recipient{to}, 22 | TemplateName: "signin_email", 23 | Props: dto.Props{ 24 | "logo": web.LogoURL(c), 25 | }, 26 | }) 27 | 28 | return nil 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /app/tasks/signup.go: -------------------------------------------------------------------------------- 1 | package tasks 2 | 3 | import ( 4 | "github.com/getfider/fider/app/actions" 5 | "github.com/getfider/fider/app/models/cmd" 6 | "github.com/getfider/fider/app/models/dto" 7 | "github.com/getfider/fider/app/pkg/bus" 8 | "github.com/getfider/fider/app/pkg/web" 9 | "github.com/getfider/fider/app/pkg/worker" 10 | ) 11 | 12 | // SendSignUpEmail is used to send the sign up email to requestor 13 | func SendSignUpEmail(action *actions.CreateTenant, baseURL string) worker.Task { 14 | return describe("Send sign up email", func(c *worker.Context) error { 15 | to := dto.NewRecipient(action.Name, action.Email, dto.Props{ 16 | "link": link(baseURL, "/signup/verify?k=%s", action.VerificationKey), 17 | }) 18 | 19 | bus.Publish(c, &cmd.SendMail{ 20 | From: dto.Recipient{Name: "Fider"}, 21 | To: []dto.Recipient{to}, 22 | TemplateName: "signup_email", 23 | Props: dto.Props{ 24 | "logo": web.LogoURL(c), 25 | }, 26 | }) 27 | 28 | return nil 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /e2e/_init_.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | require("isomorphic-fetch") 3 | 4 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" 5 | 6 | require("ts-node").register({ 7 | transpileOnly: true, 8 | compilerOptions: { 9 | target: "es6", 10 | strict: true, 11 | module: "commonjs", 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /e2e/features/server/http.feature: -------------------------------------------------------------------------------- 1 | Feature: HTTP 2 | 3 | Scenario: Cache Control is set on valid resources 4 | Given I send a "GET" request to "/assets/assets.json" 5 | Then I should see http status 200 6 | And I should see a "Cache-Control" header with value "public, max-age=31536000" 7 | 8 | Scenario: Cache Control is not set on invalid resources 9 | Given I send a "GET" request to "/assets/invalid.js" 10 | Then I should see http status 404 11 | And I should see a "Cache-Control" header with value "no-cache, no-store" -------------------------------------------------------------------------------- /e2e/features/server/prometheus.feature: -------------------------------------------------------------------------------- 1 | Feature: Prometheus 2 | 3 | Scenario: Prometheus metrics endpoint is accessible 4 | Given I send a "GET" request to "http://127.0.0.1:4000/metrics" 5 | Then I should see http status 200 6 | And I should see "TYPE fider_info gauge" on the response body -------------------------------------------------------------------------------- /e2e/features/ui/post.feature: -------------------------------------------------------------------------------- 1 | Feature: Post 2 | 3 | Scenario: Admin can create a post 4 | Given I go to the home page 5 | And I sign in as "admin" 6 | And I type "Feature Request Example" as the title 7 | And I type "This is just an example of a feature suggestion in fider" as the description 8 | And I click submit new post 9 | Then I should be on the show post page 10 | And I should see "Feature Request Example" as the post title 11 | And I should see 1 vote(s) 12 | 13 | Scenario: Non-logged in user can view a post 14 | Given I go to the home page 15 | And I search for "Feature Request Example" 16 | And I click on the first post 17 | Then I should be on the show post page 18 | And I should see "Feature Request Example" as the post title 19 | And I should see 1 vote(s) -------------------------------------------------------------------------------- /e2e/step_definitions/show_post.steps.ts: -------------------------------------------------------------------------------- 1 | import { Then } from "@cucumber/cucumber" 2 | import { FiderWorld } from "../world" 3 | import { expect } from "@playwright/test" 4 | 5 | Then("I should be on the show post page", async function (this: FiderWorld) { 6 | const container = await this.page.$$("#p-show-post") 7 | expect(container).toBeDefined() 8 | }) 9 | 10 | Then("I should see {string} as the post title", async function (this: FiderWorld, title: string) { 11 | const postTitle = await this.page.innerText("#p-show-post h1") 12 | expect(postTitle).toBe(title) 13 | }) 14 | 15 | Then("I should see {int} vote\\(s)", async function (this: FiderWorld, voteCount: number) { 16 | await expect(this.page.getByText(`${voteCount}${voteCount === 1 ? "Vote" : "Votes"}`, { exact: true })).toBeVisible() 17 | }) 18 | -------------------------------------------------------------------------------- /e2e/step_definitions/user.steps.ts: -------------------------------------------------------------------------------- 1 | import { Given } from "@cucumber/cucumber" 2 | import { FiderWorld } from "e2e/world" 3 | import { getLatestLinkSentTo, isAuthenticated, isAuthenticatedAsUser } from "./fns" 4 | 5 | Given("I sign in as {string}", async function (this: FiderWorld, userName: string) { 6 | if (await isAuthenticatedAsUser(this.page, userName)) { 7 | return 8 | } 9 | 10 | if (await isAuthenticated(this.page)) { 11 | await this.page.click(".c-menu-user .c-dropdown__handle") 12 | await this.page.click("a[href='/signout']") 13 | } 14 | 15 | const userEmail = `${userName}-${this.tenantName}@fider.io` 16 | await this.page.click(".c-menu .uppercase.text-sm") 17 | await this.page.type(".c-signin-control #input-email", userEmail) 18 | await this.page.click(".c-signin-control .c-button--primary") 19 | 20 | const activationLink = await getLatestLinkSentTo(userEmail) 21 | await this.page.goto(activationLink) 22 | }) 23 | -------------------------------------------------------------------------------- /e2e/world.ts: -------------------------------------------------------------------------------- 1 | import { World as CucumberWorld } from "@cucumber/cucumber" 2 | import { Page } from "@playwright/test" 3 | 4 | export interface FiderWorld extends CucumberWorld { 5 | tenantName: string 6 | page: Page 7 | log: (msg: string) => void 8 | } 9 | -------------------------------------------------------------------------------- /etc/browserstack.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/etc/browserstack.png -------------------------------------------------------------------------------- /etc/homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/etc/homepage.png -------------------------------------------------------------------------------- /etc/logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/etc/logo-small.png -------------------------------------------------------------------------------- /etc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/etc/logo.png -------------------------------------------------------------------------------- /etc/privacy.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | THIS IS A PLACEHOLDER FOR PRIVACY POLICY. REPLACE WITH YOUR OWN. 4 | -------------------------------------------------------------------------------- /etc/terms.md: -------------------------------------------------------------------------------- 1 | # Terms of Service 2 | 3 | THIS IS A PLACEHOLDER FOR TERMS OF SERVICE. REPLACE WITH YOUR OWN. 4 | -------------------------------------------------------------------------------- /favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/favicon.png -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | interface GetPriceResponse { 2 | price: { net: string } 3 | error?: { 4 | message: string 5 | } 6 | } 7 | 8 | interface PaddleSdk { 9 | isReady: boolean 10 | Setup(params: { vendor: number }): void 11 | Environment: { 12 | set(envName: "sandbox"): void 13 | } 14 | Checkout: { 15 | open(params: { override: string; closeCallback: () => void }): void 16 | } 17 | Product: { 18 | Prices(planId: number, callback: (resp: GetPriceResponse) => void): void 19 | } 20 | } 21 | declare interface Window { 22 | ga?: (cmd: string, evt: string, args?: any) => void 23 | set: (key: string, value: any) => void 24 | Paddle: PaddleSdk 25 | } 26 | 27 | interface SpriteSymbol { 28 | id: string 29 | viewBox: string 30 | } 31 | 32 | declare let __webpack_nonce__: string 33 | declare let __webpack_public_path__: string 34 | 35 | declare module "*.svg" { 36 | const content: SpriteSymbol 37 | export default content 38 | } 39 | -------------------------------------------------------------------------------- /lingui.config.js: -------------------------------------------------------------------------------- 1 | import { formatter } from "@lingui/format-json" 2 | 3 | export default { 4 | catalogs: [ 5 | { 6 | path: "/locale/{locale}/client", 7 | include: ["/public/**/*.{ts,tsx}"], 8 | }, 9 | ], 10 | orderBy: "messageId", 11 | fallbackLocales: { 12 | default: "en", 13 | }, 14 | sourceLocale: "en", 15 | format: formatter({ style: "minimal", explicitIdAsDefault: true, sort: true }), 16 | locales: ["pt-BR", "es-ES", "nl", "sv-SE", "fr", "de", "en", "pl", "ru", "ja", "sk", "tr", "el", "it", "zh-CN", "ar"], 17 | } 18 | -------------------------------------------------------------------------------- /locale/locales.ts: -------------------------------------------------------------------------------- 1 | interface Locale { 2 | text: string 3 | } 4 | 5 | const locales: { [key: string]: Locale } = { 6 | en: { 7 | text: "English", 8 | }, 9 | "pt-BR": { 10 | text: "Portuguese (Brazilian)", 11 | }, 12 | "es-ES": { 13 | text: "Spanish", 14 | }, 15 | de: { 16 | text: "German", 17 | }, 18 | fr: { 19 | text: "French", 20 | }, 21 | "sv-SE": { 22 | text: "Swedish", 23 | }, 24 | it: { 25 | text: "Italian", 26 | }, 27 | ja: { 28 | text: "Japanese", 29 | }, 30 | nl: { 31 | text: "Dutch", 32 | }, 33 | pl: { 34 | text: "Polish", 35 | }, 36 | ru: { 37 | text: "Russian", 38 | }, 39 | sk: { 40 | text: "Slovak", 41 | }, 42 | tr: { 43 | text: "Turkish", 44 | }, 45 | el: { 46 | text: "Greek", 47 | }, 48 | ar: { 49 | text: "Arabic", 50 | }, 51 | "zh-CN": { 52 | text: "Chinese (Simplified)", 53 | }, 54 | } 55 | 56 | export default locales 57 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/getfider/fider/app/cmd" 7 | _ "github.com/lib/pq" 8 | ) 9 | 10 | func main() { 11 | args := os.Args[1:] 12 | if len(args) > 0 && args[0] == "ping" { 13 | os.Exit(cmd.RunPing()) 14 | } else if len(args) > 0 && args[0] == "migrate" { 15 | os.Exit(cmd.RunMigrate()) 16 | } else { 17 | os.Exit(cmd.RunServer()) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /migrations/201701261850_create_tenants.up.sql: -------------------------------------------------------------------------------- 1 | create table if not exists tenants ( 2 | id serial primary key, 3 | name varchar(60) not null, 4 | domain varchar(40) not null, 5 | created_on timestamptz not null default now(), 6 | modified_on timestamptz not null default now() 7 | ); -------------------------------------------------------------------------------- /migrations/201701281131_rename_domain.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE tenants RENAME COLUMN domain TO subdomain; -------------------------------------------------------------------------------- /migrations/201702072040_create_ideas.up.sql: -------------------------------------------------------------------------------- 1 | create table if not exists ideas ( 2 | id serial primary key, 3 | title varchar(100) not null, 4 | description text null, 5 | tenant_id int not null, 6 | created_on timestamptz not null default now(), 7 | modified_on timestamptz not null default now(), 8 | foreign key (tenant_id) references tenants(id) 9 | ); -------------------------------------------------------------------------------- /migrations/201702251213_create_users.up.sql: -------------------------------------------------------------------------------- 1 | create table if not exists users ( 2 | id serial primary key, 3 | name varchar(100) null, 4 | email varchar(200) not null, 5 | created_on timestamptz not null default now(), 6 | modified_on timestamptz not null default now() 7 | ); 8 | 9 | create table if not exists user_providers ( 10 | user_id int not null, 11 | provider varchar(40) not null, 12 | provider_uid varchar(100) not null, 13 | created_on timestamptz not null default now(), 14 | modified_on timestamptz not null default now(), 15 | primary key (user_id, provider), 16 | foreign key (user_id) references users(id) 17 | ); -------------------------------------------------------------------------------- /migrations/201702251620_add_tenant_cname.up.sql: -------------------------------------------------------------------------------- 1 | alter table tenants add cname varchar(100) null -------------------------------------------------------------------------------- /migrations/201703172030_add_ideas_userid.up.sql: -------------------------------------------------------------------------------- 1 | alter table ideas add user_id int REFERENCES users (id) -------------------------------------------------------------------------------- /migrations/201703172115_remove_col_defaults.up.sql: -------------------------------------------------------------------------------- 1 | alter table ideas drop column modified_on; 2 | alter table ideas alter column created_on drop default; 3 | 4 | alter table users drop column modified_on; 5 | alter table users alter column created_on drop default; -------------------------------------------------------------------------------- /migrations/201703240709_remove_col_tenants.up.sql: -------------------------------------------------------------------------------- 1 | alter table tenants drop column modified_on; 2 | alter table tenants alter column created_on drop default; -------------------------------------------------------------------------------- /migrations/201703240710_create_comments.up.sql: -------------------------------------------------------------------------------- 1 | create table if not exists comments ( 2 | id serial primary key, 3 | content text null, 4 | idea_id int not null, 5 | user_id int not null, 6 | created_on timestamptz not null, 7 | foreign key (idea_id) references ideas(id), 8 | foreign key (user_id) references users(id) 9 | ); -------------------------------------------------------------------------------- /migrations/201703310824_remove_col_user_providers.up.sql: -------------------------------------------------------------------------------- 1 | alter table user_providers drop column modified_on; 2 | alter table user_providers alter column created_on drop default; -------------------------------------------------------------------------------- /migrations/201703310857_add_tenant_to_users.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users ADD tenant_id INT REFERENCES tenants(id); 2 | 3 | UPDATE users 4 | SET tenant_id = ideas.tenant_id 5 | FROM ideas 6 | WHERE ideas.user_id = users.id; 7 | 8 | UPDATE users 9 | SET tenant_id = (SELECT id FROM tenants LIMIT 1) 10 | WHERE tenant_id IS NULL -------------------------------------------------------------------------------- /migrations/201704101854_add_ideas_number.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE ideas ADD NUMBER INT; 2 | 3 | UPDATE ideas i 4 | SET NUMBER = i2.seqnum 5 | FROM (SELECT i2.*, row_number() OVER (PARTITION BY tenant_id ORDER BY created_on) AS seqnum FROM ideas i2) i2 6 | WHERE i2.id = i.id; 7 | 8 | ALTER TABLE ideas ALTER COLUMN NUMBER SET NOT NULL; -------------------------------------------------------------------------------- /migrations/201704112003_add_role_users.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users ADD role INT; 2 | 3 | UPDATE users SET role = 1; 4 | 5 | ALTER TABLE users ALTER COLUMN role SET NOT NULL; -------------------------------------------------------------------------------- /migrations/201704181821_add_supporters.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE ideas ADD supporters INT; 2 | 3 | UPDATE ideas SET supporters = 0; 4 | 5 | ALTER TABLE ideas ALTER COLUMN supporters SET NOT NULL; 6 | 7 | CREATE TABLE IF NOT EXISTS idea_supporters ( 8 | user_id int not null, 9 | idea_id int not null, 10 | created_on timestamptz not null, 11 | primary key (user_id, idea_id), 12 | foreign key (idea_id) references ideas(id), 13 | foreign key (user_id) references users(id) 14 | ); -------------------------------------------------------------------------------- /migrations/201705132054_add_idea_status.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE ideas ADD status INT; 2 | 3 | UPDATE ideas SET status = 0; 4 | 5 | ALTER TABLE ideas ALTER COLUMN status SET NOT NULL; -------------------------------------------------------------------------------- /migrations/201705191854_add_idea_slug.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE ideas ADD slug varchar(100); 2 | 3 | UPDATE ideas SET slug = ''; 4 | 5 | ALTER TABLE ideas ALTER COLUMN slug SET NOT NULL; -------------------------------------------------------------------------------- /migrations/201705202300_add_idea_response.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE ideas ADD response text null; 2 | ALTER TABLE ideas ADD response_user_id int null; 3 | ALTER TABLE ideas ADD response_date timestamptz null; -------------------------------------------------------------------------------- /migrations/201707081055_set_cname_null.up.sql: -------------------------------------------------------------------------------- 1 | UPDATE tenants SET cname = null WHERE cname = ''; -------------------------------------------------------------------------------- /migrations/201707261949_set_cname_empty.up.sql: -------------------------------------------------------------------------------- 1 | UPDATE tenants SET cname = '' WHERE cname IS NULL; -------------------------------------------------------------------------------- /migrations/201707271826_new_tenant_settings.up.sql: -------------------------------------------------------------------------------- 1 | alter table tenants add invitation varchar(100) null; 2 | alter table tenants add welcome_message text null; 3 | 4 | update tenants set invitation = '', welcome_message = ''; -------------------------------------------------------------------------------- /migrations/201709081837_create_email_verifications.up.sql: -------------------------------------------------------------------------------- 1 | create table if not exists email_verifications ( 2 | id serial primary key, 3 | tenant_id int not null, 4 | email varchar(200) not null, 5 | created_on timestamptz not null, 6 | key varchar(32) not null, 7 | verified_on timestamptz null, 8 | foreign key (tenant_id) references tenants(id) 9 | ); -------------------------------------------------------------------------------- /migrations/201709091228_create_email_verification_key_index.up.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX email_verification_key_idx ON email_verifications (tenant_id, key); -------------------------------------------------------------------------------- /migrations/201709141944_rename_email_verification.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE email_verifications RENAME TO signin_requests; 2 | ALTER INDEX email_verifications_pkey RENAME TO signin_requests_pkey; 3 | ALTER INDEX email_verification_key_idx RENAME TO signin_requests_key_idx; -------------------------------------------------------------------------------- /migrations/201709241236_add_tenant_status.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE tenants ADD status INT NOT NULL DEFAULT 1; 2 | ALTER TABLE tenants ALTER COLUMN status DROP DEFAULT; -------------------------------------------------------------------------------- /migrations/201709241254_add_signin_request_name_expires_on.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE signin_requests ADD name VARCHAR(200) NULL; 2 | ALTER TABLE signin_requests ADD expires_on TIMESTAMPTZ NOT NULL DEFAULT now(); 3 | 4 | UPDATE signin_requests SET expires_on = created_on + interval '15 minute'; 5 | 6 | ALTER TABLE signin_requests ALTER COLUMN expires_on DROP DEFAULT; -------------------------------------------------------------------------------- /migrations/201711152138_create_tags.up.sql: -------------------------------------------------------------------------------- 1 | create table if not exists tags ( 2 | id serial primary key, 3 | tenant_id int not null, 4 | name varchar(30) not null, 5 | slug varchar(30) not null, 6 | color varchar(6) not null, 7 | is_public boolean not null, 8 | created_on timestamptz not null, 9 | foreign key (tenant_id) references tenants(id) 10 | ); 11 | 12 | create table if not exists idea_tags ( 13 | tag_id int not null, 14 | idea_id int not null, 15 | created_on timestamptz not null, 16 | created_by_id int not null, 17 | primary key (tag_id, idea_id), 18 | foreign key (idea_id) references ideas(id), 19 | foreign key (tag_id) references tags(id), 20 | foreign key (created_by_id) references users(id) 21 | ); -------------------------------------------------------------------------------- /migrations/201711181740_create_uniq_indexes.up.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX idea_number_tenant_key ON ideas (tenant_id, number); 2 | CREATE UNIQUE INDEX tag_slug_tenant_key ON tags (tenant_id, slug); -------------------------------------------------------------------------------- /migrations/201712061924_unique_email.up.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX user_email_unique_idx ON users (tenant_id, email) WHERE email != ''; 2 | CREATE UNIQUE INDEX user_provider_unique_idx ON user_providers (user_id, provider); 3 | CREATE UNIQUE INDEX tenant_subdomain_unique_idx ON tenants (subdomain); 4 | CREATE UNIQUE INDEX tenant_cname_unique_idx ON tenants (cname) WHERE cname != ''; -------------------------------------------------------------------------------- /migrations/201712131842_unique_slug.up.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX idea_slug_tenant_key ON ideas (tenant_id, slug); 2 | -------------------------------------------------------------------------------- /migrations/201801031643_original_id.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE ideas ADD original_id INT NULL REFERENCES ideas(id); 2 | -------------------------------------------------------------------------------- /migrations/201801152006_rename_signin_requests.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE signin_requests RENAME TO email_verifications; 2 | ALTER INDEX signin_requests_pkey RENAME TO email_verifications_pkey; 3 | ALTER INDEX signin_requests_key_idx RENAME TO email_verifications_key_idx; -------------------------------------------------------------------------------- /migrations/201801152017_add_kind_email_verification.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE email_verifications ADD kind smallint NULL; 2 | 3 | UPDATE email_verifications SET kind = 1 WHERE name IS NULL OR name = ''; 4 | UPDATE email_verifications SET kind = 2 WHERE name IS NOT NULL AND name != ''; 5 | 6 | ALTER TABLE email_verifications ALTER COLUMN kind SET NOT NULL; 7 | 8 | ALTER TABLE email_verifications ADD user_id INT NULL REFERENCES users (id) -------------------------------------------------------------------------------- /migrations/201802061858_create_notification_tables.up.sql: -------------------------------------------------------------------------------- 1 | create table if not exists idea_subscribers ( 2 | user_id int not null, 3 | idea_id int not null, 4 | created_on timestamptz not null default now(), 5 | updated_on timestamptz not null default now(), 6 | status smallint not null, 7 | primary key (user_id, idea_id), 8 | foreign key (idea_id) references ideas(id), 9 | foreign key (user_id) references users(id) 10 | ); 11 | 12 | create table if not exists user_settings ( 13 | id serial primary key, 14 | user_id int not null, 15 | key varchar(100) not null, 16 | value varchar(100) null, 17 | foreign key (user_id) references users(id) 18 | ); 19 | 20 | create unique index user_settings_uq_key on user_settings (user_id, key); -------------------------------------------------------------------------------- /migrations/201802071816_seed_subscribers_table.up.sql: -------------------------------------------------------------------------------- 1 | INSERT INTO idea_subscribers (user_id, idea_id, created_on, updated_on, status) 2 | SELECT user_id, idea_id, created_on, created_on, 1 FROM idea_supporters ON CONFLICT DO NOTHING; 3 | 4 | INSERT INTO idea_subscribers (user_id, idea_id, created_on, updated_on, status) 5 | SELECT user_id, id, created_on, created_on, 1 FROM ideas ON CONFLICT DO NOTHING; 6 | 7 | INSERT INTO idea_subscribers (user_id, idea_id, created_on, updated_on, status) 8 | SELECT user_id, idea_id, created_on, created_on, 1 FROM comments ON CONFLICT DO NOTHING; -------------------------------------------------------------------------------- /migrations/201802231910_add_pg_trgm.up.sql: -------------------------------------------------------------------------------- 1 | CREATE EXTENSION IF NOT EXISTS pg_trgm; -------------------------------------------------------------------------------- /migrations/201802241348_create_webnotification_tables.up.sql: -------------------------------------------------------------------------------- 1 | create table if not exists notifications ( 2 | id serial not null, 3 | tenant_id int not null, 4 | user_id int not null, 5 | title varchar(160) not null, 6 | link varchar(2048) null, 7 | read boolean not null, 8 | idea_id int not null, 9 | author_id int not null, 10 | created_on timestamptz not null default now(), 11 | updated_on timestamptz not null default now(), 12 | primary key (id), 13 | foreign key (tenant_id) references tenants(id), 14 | foreign key (user_id) references users(id), 15 | foreign key (author_id) references users(id), 16 | foreign key (idea_id) references ideas(id) 17 | ); -------------------------------------------------------------------------------- /migrations/201803110836_edit_comment_columns.up.sql: -------------------------------------------------------------------------------- 1 | -- add tenant_id 2 | ALTER TABLE comments ADD edited_on TIMESTAMPTZ NULL; 3 | ALTER TABLE comments ADD edited_by_id INT NULL; 4 | 5 | ALTER TABLE comments 6 | ADD CONSTRAINT comments_edited_by_id_fkey 7 | FOREIGN KEY (edited_by_id, tenant_id) 8 | REFERENCES users(id, tenant_id); -------------------------------------------------------------------------------- /migrations/201804091842_add_is_private.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE tenants ADD is_private BOOLEAN NULL; 2 | 3 | UPDATE tenants SET is_private = false; 4 | 5 | ALTER TABLE tenants ALTER COLUMN is_private SET NOT NULL; -------------------------------------------------------------------------------- /migrations/201805061319_create_uploads.up.sql: -------------------------------------------------------------------------------- 1 | create table if not exists uploads ( 2 | id serial not null, 3 | tenant_id int not null, 4 | size int not null, 5 | content_type varchar(200) not null, 6 | file bytea not null, 7 | created_on timestamptz not null default now(), 8 | primary key (id), 9 | foreign key (tenant_id) references tenants(id) 10 | ); -------------------------------------------------------------------------------- /migrations/201805070759_add_logo_id.up.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX upload_tenant_key ON uploads (id, tenant_id); 2 | 3 | ALTER TABLE tenants ADD logo_id INT NULL; 4 | 5 | ALTER TABLE tenants 6 | ADD CONSTRAINT tenants_logo_id_fkey 7 | FOREIGN KEY (logo_id, id) 8 | REFERENCES uploads(id, tenant_id); 9 | -------------------------------------------------------------------------------- /migrations/201805162034_add_custom_css.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE tenants ADD custom_css TEXT NULL; 2 | 3 | UPDATE tenants SET custom_css = ''; 4 | 5 | ALTER TABLE tenants ALTER COLUMN custom_css SET NOT NULL; -------------------------------------------------------------------------------- /migrations/201805230000_drop_supporters_column.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE ideas DROP COLUMN supporters; -------------------------------------------------------------------------------- /migrations/201805261834_add_user_status.up.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users ADD status INT NULL; 2 | 3 | UPDATE users SET status = 1; 4 | 5 | ALTER TABLE users ALTER COLUMN status SET NOT NULL; -------------------------------------------------------------------------------- /migrations/201806191904_create_logs.sql: -------------------------------------------------------------------------------- 1 | create table if not exists logs ( 2 | id serial not null, 3 | tag varchar(50) not null, 4 | level varchar(50) not null, 5 | text text not null, 6 | properties jsonb null, 7 | created_on timestamptz not null default now(), 8 | primary key (id) 9 | ); -------------------------------------------------------------------------------- /migrations/201808181931_add_api_key_users.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users ADD api_key VARCHAR(32) NULL; 2 | ALTER TABLE users ADD api_key_date TIMESTAMPTZ NULL; 3 | 4 | CREATE UNIQUE INDEX users_api_key ON users (tenant_id, api_key); -------------------------------------------------------------------------------- /migrations/201808192103_increase_key_size.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users ALTER COLUMN api_key TYPE VARCHAR(64); 2 | ALTER TABLE email_verifications ALTER COLUMN key TYPE VARCHAR(64); 3 | -------------------------------------------------------------------------------- /migrations/201808291958_rename_support_vote.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE post_supporters RENAME TO post_votes; 2 | ALTER INDEX post_supporters_pkey RENAME TO post_votes_pkey; 3 | ALTER TABLE post_votes RENAME CONSTRAINT post_supporters_post_id_fkey TO post_votes_post_id_fkey; 4 | ALTER TABLE post_votes RENAME CONSTRAINT post_supporters_tenant_id_fkey TO post_votes_tenant_id_fkey; 5 | ALTER TABLE post_votes RENAME CONSTRAINT post_supporters_user_id_fkey TO post_votes_user_id_fkey; 6 | 7 | UPDATE tenants 8 | SET custom_css = replace(custom_css, '.c-support-counter', '.c-vote-counter') 9 | WHERE custom_css LIKE '%.c-support-counter%'; 10 | 11 | UPDATE tenants 12 | SET custom_css = replace(custom_css, '.m-supported', '.m-voted') 13 | WHERE custom_css LIKE '%.m-supported%'; -------------------------------------------------------------------------------- /migrations/201810022329_add_events.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS events ( 2 | id SERIAL NOT NULL, 3 | tenant_id INT NOT NULL, 4 | client_ip INET, 5 | name VARCHAR(64) NOT NULL, 6 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 7 | PRIMARY KEY (id), 8 | FOREIGN KEY (tenant_id) REFERENCES tenants(id) 9 | ); -------------------------------------------------------------------------------- /migrations/201810152035_add_comment_deleted.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE comments ADD deleted_at TIMESTAMPTZ NULL; 2 | ALTER TABLE comments ADD deleted_by_id INT NULL; 3 | 4 | ALTER TABLE comments 5 | ADD CONSTRAINT comments_deleted_by_id_fkey 6 | FOREIGN KEY (deleted_by_id, tenant_id) 7 | REFERENCES users(id, tenant_id); -------------------------------------------------------------------------------- /migrations/201811071547_increase_link_size.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE notifications ALTER COLUMN title TYPE VARCHAR(400); 2 | -------------------------------------------------------------------------------- /migrations/201812102208_create_blob_table.sql: -------------------------------------------------------------------------------- 1 | create table if not exists blobs ( 2 | id serial not null, 3 | key varchar(512) not null, 4 | tenant_id int null, 5 | size bigint not null, 6 | content_type varchar(200) not null, 7 | file bytea not null, 8 | created_at timestamptz not null default now(), 9 | modified_at timestamptz not null default now(), 10 | primary key (id), 11 | unique (tenant_id, key), 12 | foreign key (tenant_id) references tenants(id) 13 | ); -------------------------------------------------------------------------------- /migrations/201812201644_migrate_autocert_to_blobs.sql: -------------------------------------------------------------------------------- 1 | CREATE UNIQUE INDEX blobs_unique_global_key ON blobs (key, tenant_id) WHERE tenant_id IS NOT NULL; 2 | CREATE UNIQUE INDEX blobs_unique_tenant_key ON blobs (key) WHERE tenant_id IS NULL; -------------------------------------------------------------------------------- /migrations/201812230904_user_avatar_type.sql: -------------------------------------------------------------------------------- 1 | alter table users add column avatar_type smallint null; 2 | alter table users add column avatar_bkey varchar(512) null; 3 | 4 | update users set avatar_type = 2; -- gravatar 5 | update users set avatar_bkey = ''; 6 | 7 | alter table users alter column avatar_type set not null; 8 | alter table users alter column avatar_bkey set not null; -------------------------------------------------------------------------------- /migrations/201901042021_create_tenants_billing.sql: -------------------------------------------------------------------------------- 1 | create table if not exists tenants_billing ( 2 | tenant_id int not null, 3 | trial_ends_at timestamptz not null, 4 | subscription_ends_at timestamptz null, 5 | stripe_customer_id varchar(255) null, 6 | stripe_subscription_id varchar(255) null, 7 | stripe_plan_id varchar(255) null, 8 | primary key (tenant_id), 9 | foreign key (tenant_id) references tenants(id) 10 | ); -------------------------------------------------------------------------------- /migrations/201901072106_recreate_post_slug_index.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX post_slug_tenant_key; 2 | CREATE UNIQUE INDEX post_slug_tenant_key ON posts (tenant_id, slug) WHERE status <> 6; -------------------------------------------------------------------------------- /migrations/201901130830_attachments.sql: -------------------------------------------------------------------------------- 1 | create table if not exists attachments ( 2 | id serial not null, 3 | tenant_id int not null, 4 | post_id int not null, 5 | comment_id int null, 6 | user_id int not null, 7 | attachment_bkey varchar(512) not null, 8 | primary key (id), 9 | foreign key (tenant_id) references tenants(id), 10 | foreign key (post_id) references posts(id), 11 | foreign key (user_id) references users(id), 12 | foreign key (comment_id) references comments(id) 13 | ); -------------------------------------------------------------------------------- /migrations/201904022134_lowercase_emails.sql: -------------------------------------------------------------------------------- 1 | update users set email = lower(email) where email != lower(email) -------------------------------------------------------------------------------- /migrations/201904091921_fix_api_users_role.sql: -------------------------------------------------------------------------------- 1 | UPDATE users SET role = 1 WHERE role = 0 -------------------------------------------------------------------------------- /migrations/202105161823_create_indexes.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX IF NOT EXISTS post_tags_post_id_fkey ON post_tags (tenant_id,post_id); 2 | CREATE INDEX IF NOT EXISTS comments_post_id_fkey ON comments (tenant_id,post_id); 3 | CREATE INDEX IF NOT EXISTS post_votes_post_id_fkey ON post_votes (tenant_id,post_id); 4 | CREATE INDEX IF NOT EXISTS post_subscribers_post_id_fkey ON post_subscribers (tenant_id,post_id); -------------------------------------------------------------------------------- /migrations/202107031320_add_locale_field.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE tenants ADD locale VARCHAR(10) NULL; 2 | UPDATE tenants SET locale = 'en'; 3 | ALTER TABLE tenants ALTER COLUMN locale SET NOT NULL; -------------------------------------------------------------------------------- /migrations/202107211126_added_allowing_email_auth.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE tenants ADD COLUMN is_email_auth_allowed BOOLEAN; 2 | UPDATE tenants SET is_email_auth_allowed = TRUE; 3 | ALTER TABLE tenants ALTER COLUMN is_email_auth_allowed SET NOT NULL; -------------------------------------------------------------------------------- /migrations/202108092243_create_webhooks.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS webhooks ( 2 | id SERIAL PRIMARY KEY, 3 | name VARCHAR(60) NOT NULL, 4 | type SMALLINT NOT NULL, 5 | status SMALLINT NOT NULL, 6 | url TEXT NOT NULL, 7 | content TEXT NULL, 8 | http_method VARCHAR(50) NOT NULL, 9 | http_headers JSONB NULL, 10 | tenant_id INT NOT NULL, 11 | FOREIGN KEY (tenant_id) REFERENCES tenants (id) 12 | ); -------------------------------------------------------------------------------- /migrations/202109052023_email_supressed_at.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE users ADD email_supressed_at TIMESTAMPTZ NULL; -------------------------------------------------------------------------------- /migrations/202109072130_paddle_fields.sql: -------------------------------------------------------------------------------- 1 | alter table tenants_billing drop column stripe_customer_id; 2 | alter table tenants_billing drop column stripe_subscription_id; 3 | alter table tenants_billing drop column stripe_plan_id; 4 | 5 | alter table tenants_billing add paddle_subscription_id varchar(255) not null; 6 | alter table tenants_billing add paddle_plan_id varchar(255) not null; 7 | alter table tenants_billing add status smallint not null; -------------------------------------------------------------------------------- /migrations/202109272130_system_settings.sql: -------------------------------------------------------------------------------- 1 | create table if not exists system_settings ( 2 | key varchar(100) primary key, 3 | value text not null 4 | ); -------------------------------------------------------------------------------- /migrations/202205082055_trusted_provider.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE oauth_providers ADD is_trusted BOOLEAN default false; 2 | 3 | CREATE UNIQUE INDEX oauth_provider_uq ON oauth_providers (provider); -------------------------------------------------------------------------------- /migrations/202406111146_add_posts_user_id_index.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX post_user_key ON posts (user_id); 2 | -------------------------------------------------------------------------------- /migrations/202410122105_create_reactions_up.sql: -------------------------------------------------------------------------------- 1 | create table if not exists reactions ( 2 | id serial primary key, 3 | emoji varchar(8) not null, 4 | comment_id int not null, 5 | user_id int not null, 6 | created_on timestamptz not null, 7 | foreign key (comment_id) references comments(id), 8 | foreign key (user_id) references users(id) 9 | ); 10 | 11 | ALTER TABLE reactions ADD CONSTRAINT unique_reaction UNIQUE (comment_id, user_id, emoji); 12 | -------------------------------------------------------------------------------- /migrations/202503202000_mentions_notifications.sql: -------------------------------------------------------------------------------- 1 | create table 2 | if not exists mention_notifications ( 3 | id serial not null, 4 | tenant_id int not null, 5 | user_id int not null, 6 | comment_id int null, 7 | created_on timestamptz not null default now (), 8 | primary key (id), 9 | foreign key (tenant_id) references tenants (id), 10 | foreign key (user_id) references users (id), 11 | foreign key (comment_id) references comments (id), 12 | constraint unique_mention_notification unique (tenant_id, user_id, comment_id) 13 | ); 14 | 15 | create index idx_mention_notifications_tenant_user on mention_notifications (tenant_id, user_id); 16 | 17 | UPDATE comments 18 | SET 19 | content = regexp_replace ( 20 | content, 21 | '@{"id":([0-9]+),"name":"([^"]+)"(,"isNew":(true|false))?}', 22 | '@[\2]', 23 | 'g' 24 | ) 25 | WHERE 26 | content LIKE '%@{"id":%' -------------------------------------------------------------------------------- /public/assets/images/cc-diners.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /public/assets/images/fa-caretup.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heriocons-underline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-at.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-bell.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-bold.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-bulletlist.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-chat-alt-2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-check-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-code.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-cog.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-dots-horizontal.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-download.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-duplicate.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-exclamation-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-exclamation.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-eye.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-filter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-h2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-h3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-inbox.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-information-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-italic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-light-bulb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-lightbulb.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-menu.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-moon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-orderedlist.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-pencil-alt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-photograph.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-pluscircle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-selector.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-shieldcheck.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-smile.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-sparkles-outline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-speakerphone.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-strike.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-sun.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-thumbsdown.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-thumbsup.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-volume-off.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-volume-on.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-x-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | -------------------------------------------------------------------------------- /public/assets/images/heroicons-x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /public/assets/images/reaction-add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/assets/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import "variables.scss"; 2 | @import "reset.scss"; 3 | @import "utility.scss"; 4 | @import "tooltip.scss"; 5 | -------------------------------------------------------------------------------- /public/assets/styles/tooltip.scss: -------------------------------------------------------------------------------- 1 | [data-tooltip] { 2 | position: relative; 3 | 4 | &::before { 5 | background-color: var(--colors-gray-700); 6 | color: var(--colors-white); 7 | font-size: 11px; 8 | padding: 4px 6px; 9 | width: max-content; 10 | border-radius: 4px; 11 | position: absolute; 12 | text-align: center; 13 | bottom: 40px; 14 | font-weight: 500; 15 | left: 50%; 16 | content: attr(data-tooltip); 17 | transform: translate(-50%, 100%) scale(0); 18 | transition: 0.1s; 19 | } 20 | 21 | &:hover:before { 22 | display: block; 23 | transform: translate(-50%, 100%) scale(1); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /public/assets/styles/utility.scss: -------------------------------------------------------------------------------- 1 | @import "./utility/display.scss"; 2 | @import "./utility/colors.scss"; 3 | @import "./utility/grid.scss"; 4 | @import "./utility/sizing.scss"; 5 | @import "./utility/text.scss"; 6 | @import "./utility/page.scss"; 7 | @import "./utility/outline.scss"; 8 | @import "./utility/spacing.scss"; 9 | -------------------------------------------------------------------------------- /public/assets/styles/utility/colors.scss: -------------------------------------------------------------------------------- 1 | @import "../variables/_colors.scss"; 2 | @import "../variables/_dark-colors.scss"; 3 | @import "../utility/_theme.scss"; 4 | -------------------------------------------------------------------------------- /public/assets/styles/utility/grid.scss: -------------------------------------------------------------------------------- 1 | .grid { 2 | display: grid; 3 | } 4 | 5 | .grid-cols-1 { 6 | grid-template-columns: repeat(1, minmax(0, 1fr)); 7 | } 8 | 9 | .grid-cols-2 { 10 | grid-template-columns: repeat(2, minmax(0, 1fr)); 11 | } 12 | 13 | .grid-cols-3 { 14 | grid-template-columns: repeat(3, minmax(0, 1fr)); 15 | } 16 | 17 | .grid-cols-4 { 18 | grid-template-columns: repeat(4, minmax(0, 1fr)); 19 | } 20 | 21 | @include media("lg") { 22 | .lg\:grid-cols-3 { 23 | grid-template-columns: repeat(3, minmax(0, 1fr)); 24 | } 25 | .lg\:grid-cols-5 { 26 | grid-template-columns: repeat(5, minmax(0, 1fr)); 27 | } 28 | } 29 | 30 | .col-span-3 { 31 | grid-column: span 3 / span 3; 32 | } 33 | 34 | @for $i from 0 through 8 { 35 | .gap-#{$i} { 36 | gap: spacing($i); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/assets/styles/utility/outline.scss: -------------------------------------------------------------------------------- 1 | *:focus { 2 | outline: 0; 3 | &:not(.no-focus) { 4 | border-color: get("colors.primary.light") !important; 5 | box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px, get("colors.primary.light") 0px 0px 0px 1px, rgba(0, 0, 0, 0) 0px 0px 0px 0px !important; 6 | } 7 | transition: all 0.3s ease; 8 | } 9 | -------------------------------------------------------------------------------- /public/assets/styles/utility/page.scss: -------------------------------------------------------------------------------- 1 | #root { 2 | height: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .page { 8 | padding-top: spacing(5); 9 | padding-bottom: spacing(4); 10 | } 11 | 12 | @include media("sm") { 13 | .page { 14 | width: calc(100vw - 20px); 15 | margin-inline-start: 10px; 16 | margin-inline-end: 10px; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /public/assets/styles/utility/sizing.scss: -------------------------------------------------------------------------------- 1 | @for $i from 0 through 20 { 2 | .w-#{$i} { 3 | width: sizing($i); 4 | } 5 | .h-#{$i} { 6 | height: sizing($i); 7 | } 8 | .w-min-#{$i} { 9 | min-width: sizing($i); 10 | } 11 | } 12 | 13 | @for $i from 0 through 20 { 14 | .w-max-#{$i}xl { 15 | max-width: sizing($i * 20); 16 | } 17 | .h-max-#{$i}xl { 18 | max-height: sizing($i * 20); 19 | } 20 | } 21 | 22 | .w-full { 23 | width: 100%; 24 | } 25 | -------------------------------------------------------------------------------- /public/assets/styles/variables/_sizing.scss: -------------------------------------------------------------------------------- 1 | $spacing-inc: 4px; 2 | 3 | @function spacing($i) { 4 | @return $spacing-inc * $i; 5 | } 6 | -------------------------------------------------------------------------------- /public/assets/styles/variables/_spacing.scss: -------------------------------------------------------------------------------- 1 | $sizing-inc: 4px; 2 | 3 | @function sizing($i) { 4 | @return $sizing-inc * $i; 5 | } 6 | -------------------------------------------------------------------------------- /public/assets/styles/variables/_text.scss: -------------------------------------------------------------------------------- 1 | $font-base: "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, "Noto Sans", sans-serif, 2 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 3 | $font-code: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 4 | 5 | $font: ( 6 | family: ( 7 | base: $font-base, 8 | code: $font-code, 9 | ), 10 | size: ( 11 | "2xs": 12px, 12 | xs: 13px, 13 | sm: 14px, 14 | base: 16px, 15 | lg: 18px, 16 | xl: 22px, 17 | "2xl": 26px, 18 | ), 19 | weight: ( 20 | light: 300, 21 | normal: 400, 22 | medium: 500, 23 | semibold: 600, 24 | bold: 700, 25 | ), 26 | ); 27 | -------------------------------------------------------------------------------- /public/components/NotificationIndicator.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-notification-indicator { 4 | position: relative; 5 | 6 | .c-notification-indicator-unread-counter { 7 | position: absolute; 8 | top: 2px; 9 | right: 2px; 10 | background-color: var(--colors-red-500); 11 | height: sizing(2); 12 | width: sizing(2); 13 | border-radius: 100%; 14 | } 15 | } 16 | 17 | .c-notifications-container { 18 | max-height: 80vh; 19 | overflow-y: auto; 20 | @include media("lg") { 21 | min-width: 400px; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /public/components/Reactions.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-reactions { 4 | 5 | &-add-reaction { 6 | svg { 7 | position: relative; 8 | top:1px; 9 | left:0; 10 | } 11 | } 12 | 13 | &-emojis { 14 | top: -30px; 15 | } 16 | 17 | button { 18 | background-color: 'pink'; 19 | } 20 | } -------------------------------------------------------------------------------- /public/components/ReadOnlyNotice.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { useFider } from "@fider/hooks" 3 | import { Message } from "./common" 4 | 5 | export const ReadOnlyNotice = () => { 6 | const fider = useFider() 7 | if (!fider.isReadOnly) { 8 | return null 9 | } 10 | 11 | if (fider.session.isAuthenticated && fider.session.user.isAdministrator) { 12 | return ( 13 | 14 | This website is currently in read-only mode because there is no active subscription. Visit{" "} 15 | 16 | Billing 17 | {" "} 18 | to subscribe. 19 | 20 | ) 21 | } 22 | 23 | return ( 24 | 25 | This website is currently in read-only mode. 26 | 27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /public/components/ShowPostStatus.tsx: -------------------------------------------------------------------------------- 1 | // import "./ShowPostStatus.scss" 2 | 3 | import React from "react" 4 | import { PostStatus } from "@fider/models" 5 | import { i18n } from "@lingui/core" 6 | 7 | interface ShowPostStatusProps { 8 | status: PostStatus 9 | } 10 | 11 | export const ShowPostStatus = (props: ShowPostStatusProps) => { 12 | const id = `enum.poststatus.${props.status.value}` 13 | const title = i18n._(id, { message: props.status.title }) 14 | 15 | return {title} 16 | } 17 | -------------------------------------------------------------------------------- /public/components/ShowTag.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-tag { 4 | display: inline-flex; 5 | justify-content: center; 6 | align-items: center; 7 | font-size: 13px; 8 | color: var(--colors-gray-900); 9 | background-color: var(--colors-gray-200); 10 | font-weight: 500; 11 | border-radius: 5px; 12 | // line-height: 18px; 13 | padding: 6px 10px; 14 | border: 0; 15 | 16 | &[href] { 17 | opacity: 0.9; 18 | transition: opacity 0.1s; 19 | 20 | &:hover { 21 | opacity: 1; 22 | } 23 | } 24 | 25 | span { 26 | margin-inline-end: 7px; 27 | width: 12px; 28 | height: 12px; 29 | border-radius: 50%; 30 | display: inline-block; 31 | } 32 | 33 | &--circular { 34 | min-height: 0; 35 | min-width: 0; 36 | overflow: hidden; 37 | border-radius: 50%; 38 | padding: 6px; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /public/components/ThemeSwitcher.scss: -------------------------------------------------------------------------------- 1 | .c-themeswitcher { 2 | border: none; 3 | background: transparent; 4 | color: var(--colors-gray-700); 5 | cursor: pointer; 6 | } 7 | -------------------------------------------------------------------------------- /public/components/VoteCounter.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-vote-counter { 4 | &__button { 5 | font-size: get("font.size.lg"); 6 | // border: none; 7 | width: sizing(11); 8 | font-weight: get("font.weight.bold"); 9 | cursor: pointer; 10 | // background-color: transparent; 11 | text-align: center; 12 | margin: 0 auto; 13 | padding: 3px 0 8px 0; 14 | color: var(--colors-gray-700); 15 | display: flex; 16 | flex-direction: column; 17 | align-items: center; 18 | 19 | svg { 20 | color: var(--colors-gray-400); 21 | margin-bottom: -2px; 22 | } 23 | 24 | &--voted, 25 | &:hover { 26 | color: var(--colors-primary-base); 27 | svg { 28 | color: var(--colors-primary-base); 29 | } 30 | } 31 | 32 | &--disabled { 33 | @include disabled(); 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/components/common/Avatar.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-avatar { 4 | border-radius: 50%; 5 | vertical-align: middle; 6 | display: inline-block; 7 | } 8 | -------------------------------------------------------------------------------- /public/components/common/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import "./Avatar.scss" 2 | 3 | import React from "react" 4 | import { UserRole } from "@fider/models" 5 | 6 | interface AvatarProps { 7 | user: { 8 | role?: UserRole 9 | avatarURL: string 10 | name: string 11 | } 12 | size?: "small" | "normal" 13 | } 14 | 15 | export const Avatar = (props: AvatarProps) => { 16 | const size = props.size === "small" ? "h-6 w-6" : "h-8 w-8" 17 | return {props.user.name} 18 | } 19 | -------------------------------------------------------------------------------- /public/components/common/AvatarStack.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-avatar-stack { 4 | > * { 5 | border: 1px solid var(--colors-gray-300); 6 | } 7 | 8 | &--overlap & > * + * { 9 | margin-inline-start: spacing(-3); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /public/components/common/AvatarStack.tsx: -------------------------------------------------------------------------------- 1 | import "./AvatarStack.scss" 2 | 3 | import React from "react" 4 | import { UserRole } from "@fider/models" 5 | import { Avatar } from "./Avatar" 6 | import { classSet } from "@fider/services" 7 | 8 | interface AvatarStackProps { 9 | overlap?: boolean 10 | users: Array<{ 11 | role?: UserRole 12 | avatarURL: string 13 | name: string 14 | }> 15 | } 16 | 17 | export const AvatarStack = (props: AvatarStackProps) => { 18 | const classes = classSet({ 19 | "c-avatar-stack": true, 20 | "c-avatar-stack--overlap": props.overlap ?? true, 21 | }) 22 | 23 | return ( 24 |
25 | {props.users.map((x, i) => ( 26 | 27 | ))} 28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /public/components/common/DevBanner.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-dev-banner { 4 | position: fixed; 5 | top: spacing(2); 6 | left: spacing(2); 7 | padding: spacing(2); 8 | font-size: get("font.size.base"); 9 | color: var(--colors-red-700); 10 | border: 2px solid var(--colors-red-700); 11 | background-color: var(--colors-red-50); 12 | opacity: 0.7; 13 | } 14 | -------------------------------------------------------------------------------- /public/components/common/DevBanner.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { useFider } from "@fider/hooks" 3 | 4 | import "./DevBanner.scss" 5 | 6 | export const DevBanner = () => { 7 | const fider = useFider() 8 | 9 | if (fider.isProduction()) { 10 | return null 11 | } 12 | 13 | return
DEV
14 | } 15 | -------------------------------------------------------------------------------- /public/components/common/Hint.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-hint { 4 | position: relative; 5 | padding: spacing(4); 6 | margin-bottom: spacing(2); 7 | color: var(--colors-blue-800); 8 | border-inline-start: 2px solid var(--colors-blue-800); 9 | background-color: var(--colors-blue-50); 10 | text-align: left; 11 | 12 | span { 13 | flex: 1; 14 | } 15 | 16 | &__close { 17 | cursor: pointer; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /public/components/common/HoverInfo.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-hoverinfo { 4 | margin-inline-start: spacing(1); 5 | 6 | &__icon { 7 | vertical-align: middle; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /public/components/common/HoverInfo.tsx: -------------------------------------------------------------------------------- 1 | import "./HoverInfo.scss" 2 | 3 | import React from "react" 4 | import { Icon } from "./Icon" 5 | 6 | import IconInformationCircle from "@fider/assets/images/heroicons-information-circle.svg" 7 | import { classSet } from "@fider/services" 8 | 9 | interface InfoProps { 10 | text: string 11 | onClick?: () => void 12 | href?: string 13 | target?: "_self" | "_blank" | "_parent" | "_top" 14 | } 15 | 16 | export const HoverInfo = (props: InfoProps) => { 17 | const Elem = props.href ? "a" : "span" 18 | const classList = classSet({ 19 | "c-hoverinfo": true, 20 | clickable: props.onClick !== undefined, 21 | }) 22 | return ( 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /public/components/common/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | interface IconProps { 4 | sprite: SpriteSymbol | string 5 | height?: string 6 | width?: string 7 | className?: string 8 | onClick?: () => void 9 | } 10 | 11 | export const Icon = (props: IconProps) => { 12 | if (typeof props.sprite === "string") { 13 | const styles = { height: props.height && `${props.height}px`, width: props.width && `${props.width}px` } 14 | return 15 | } 16 | 17 | return ( 18 | 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /public/components/common/Loader.tsx: -------------------------------------------------------------------------------- 1 | import "./Loader.scss" 2 | 3 | import React, { useState } from "react" 4 | import { useTimeout } from "@fider/hooks" 5 | import { classSet } from "@fider/services" 6 | 7 | interface LoaderProps { 8 | text?: string 9 | className?: string 10 | } 11 | 12 | export function Loader(props: LoaderProps) { 13 | const [show, setShow] = useState(false) 14 | 15 | useTimeout(() => { 16 | setShow(true) 17 | }, 500) 18 | 19 | const className = classSet({ 20 | "c-loader": true, 21 | [props.className || ""]: props.className, 22 | }) 23 | 24 | return show ? ( 25 |
26 |
27 | {props.text && {props.text}} 28 |
29 | ) : null 30 | } 31 | -------------------------------------------------------------------------------- /public/components/common/Markdown.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { markdown, truncate } from "@fider/services" 3 | 4 | import "./Markdown.scss" 5 | 6 | interface MarkdownProps { 7 | className?: string 8 | text?: string 9 | maxLength?: number 10 | style: "full" | "plainText" 11 | } 12 | 13 | export const Markdown = (props: MarkdownProps) => { 14 | if (!props.text) { 15 | return null 16 | } 17 | 18 | const html = markdown[props.style](props.text) 19 | const className = `c-markdown ${props.className || ""}` 20 | const tagName = props.style === "plainText" ? "p" : "div" 21 | 22 | return React.createElement(tagName, { 23 | className, 24 | dangerouslySetInnerHTML: { __html: props.maxLength ? truncate(html, props.maxLength) : html }, 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /public/components/common/Message.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-message { 4 | padding: spacing(4); 5 | margin-bottom: spacing(2); 6 | 7 | &--icon { 8 | border-inline-start-width: 2px; 9 | border-inline-start-style: solid; 10 | } 11 | 12 | &--success { 13 | color: var(--colors-green-800); 14 | border-color: var(--colors-green-800); 15 | background-color: var(--colors-green-50); 16 | } 17 | 18 | &--warning { 19 | color: var(--colors-yellow-800); 20 | border-color: var(--colors-yellow-800); 21 | background-color: var(--colors-yellow-50); 22 | } 23 | 24 | &--error { 25 | color: var(--colors-red-800); 26 | border-color: var(--colors-red-800); 27 | background-color: var(--colors-red-50); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /public/components/common/Money.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | interface MomentProps { 4 | locale: string 5 | amount: number 6 | currency: string 7 | } 8 | 9 | export const Money = (props: MomentProps) => { 10 | const formatter = new Intl.NumberFormat(props.locale, { 11 | style: "currency", 12 | currency: props.currency, 13 | }) 14 | 15 | return {formatter.format(props.amount)} 16 | } 17 | -------------------------------------------------------------------------------- /public/components/common/PageTitle.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { classSet } from "@fider/services" 3 | 4 | interface PageTitleLogo { 5 | title: string 6 | subtitle?: string 7 | className?: string 8 | } 9 | 10 | export const PageTitle = (props: PageTitleLogo) => { 11 | const className = classSet({ 12 | "mb-4": true, 13 | [`${props.className}`]: props.className, 14 | }) 15 | 16 | return ( 17 |
18 |
{props.title}
19 |
{props.subtitle}
20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /public/components/common/PoweredByFider.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-powered { 4 | text-align: center; 5 | 6 | a { 7 | color: var(--colors-blue-700); 8 | font-size: 12px; 9 | } 10 | a:hover { 11 | color: var(--colors-gray-900); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /public/components/common/PoweredByFider.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { classSet } from "@fider/services" 3 | 4 | import "./PoweredByFider.scss" 5 | 6 | interface PoweredByFiderProps { 7 | slot: string 8 | className?: string 9 | } 10 | 11 | export const PoweredByFider = (props: PoweredByFiderProps) => { 12 | const source = encodeURIComponent(window?.location?.host || "") 13 | const medium = "powered-by" 14 | const campaign = props.slot 15 | 16 | const className = classSet({ 17 | "c-powered": true, 18 | [props.className || ""]: props.className, 19 | }) 20 | 21 | return ( 22 |
23 | 24 | Powered by Fider ⚡ 25 | 26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /public/components/common/SignInControl.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-signin-control { 4 | padding-left: 45px; 5 | padding-right: 45px; 6 | padding-bottom: 40px; 7 | 8 | &__oauth { 9 | display: flex; 10 | flex-direction: column; 11 | align-content: stretch; 12 | width: 100%; 13 | 14 | > *:not(:last-child) { 15 | margin-bottom: spacing(3); 16 | } 17 | } 18 | } 19 | 20 | .c-signin-social-button { 21 | background: none; 22 | border: 1px solid get("colors.gray.700"); 23 | border-radius: 6px; 24 | display: inline-flex; 25 | justify-content: center; 26 | align-items: center; 27 | padding-top: spacing(3); 28 | padding-bottom: spacing(3); 29 | img { 30 | height: sizing(5); 31 | padding-right: spacing(3); 32 | } 33 | span { 34 | color: get("colors.gray.700"); 35 | font-size: get("font.size.base"); 36 | font-weight: 500; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /public/components/common/UserName.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-username { 4 | color: var(--colors-gray-900); 5 | font-weight: 600; 6 | display: inline-flex; 7 | align-items: center; 8 | 9 | &--email { 10 | margin-inline-start: 10px; 11 | color: var(--colors-gray-600); 12 | font-size: get("font.size.xs"); 13 | font-weight: 400; 14 | } 15 | 16 | &--staff { 17 | color: var(--colors-primary-base); 18 | border-color: var(--colors-primary-base); 19 | 20 | div { 21 | svg { 22 | height: 14px; 23 | vertical-align: text-bottom; 24 | margin-inline-start: 2px; 25 | align-items: flex-end; 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /public/components/common/form/Checkbox.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-checkbox input { 4 | margin: 0px; 5 | appearance: none; 6 | border: 1px solid var(--colors-gray-300); 7 | height: sizing(4); 8 | width: sizing(4); 9 | color: var(--colors-primary-base); 10 | -webkit-appearance: none; 11 | appearance: none; 12 | 13 | &:checked { 14 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); 15 | } 16 | 17 | &:checked { 18 | border-color: transparent; 19 | background-color: currentColor; 20 | background-size: 100% 100%; 21 | background-position: center; 22 | background-repeat: no-repeat; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /public/components/common/form/DisplayError.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-form-error { 4 | color: var(--colors-red-600); 5 | font-size: get("font.size.sm"); 6 | ul { 7 | list-style: none; 8 | padding-inline-start: sizing(1); 9 | margin: 0; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /public/components/common/form/Form.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-form-field { 4 | margin-bottom: spacing(7); 5 | 6 | > label { 7 | display: block; 8 | font-size: get("font.size.base"); 9 | margin-bottom: spacing(1); 10 | font-weight: 500; 11 | } 12 | 13 | &:last-child { 14 | &:not(.flex-x > &) { 15 | margin-bottom: 0; 16 | } 17 | .flex-x > & { 18 | // margin-bottom: spacing(2); 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /public/components/common/form/Form.tsx: -------------------------------------------------------------------------------- 1 | import "./Form.scss" 2 | 3 | import React from "react" 4 | import { Failure, classSet } from "@fider/services" 5 | import { DisplayError } from "@fider/components" 6 | 7 | interface ValidationContext { 8 | error?: Failure 9 | } 10 | 11 | interface FormProps { 12 | children?: React.ReactNode 13 | className?: string 14 | error?: Failure 15 | } 16 | 17 | export const ValidationContext = React.createContext({}) 18 | 19 | export const Form: React.FunctionComponent = (props) => { 20 | const className = classSet({ 21 | "c-form": true, 22 | [props.className || ""]: props.className, 23 | }) 24 | 25 | return ( 26 |
27 | 28 | {props.children} 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /public/components/common/form/ImageUploader.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-image-upload { 4 | input[type="file"] { 5 | display: none; 6 | } 7 | 8 | .preview { 9 | position: relative; 10 | display: inline-block; 11 | img { 12 | padding: 5px; 13 | min-width: 50px; 14 | min-height: 50px; 15 | border: 1px solid var(--colors-gray-300); 16 | cursor: pointer; 17 | } 18 | .c-button { 19 | position: absolute; 20 | top: 4px; 21 | right: 4px; 22 | border-radius: 50%; 23 | padding: 4px 6px; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /public/components/common/form/ImageViewer.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-image-viewer { 4 | display: inline-block; 5 | cursor: pointer; 6 | margin-top: 10px; 7 | img { 8 | vertical-align: top; 9 | } 10 | + .c-image-viewer { 11 | margin-inline-start: 10px; 12 | } 13 | } 14 | 15 | .c-image-viewer-modal { 16 | img { 17 | max-width: 90vw; 18 | max-height: 80vh; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /public/components/common/form/MultiImageUploader.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-multi-image-uploader { 4 | padding-top: spacing(2); 5 | .c-multi-image-uploader-instances { 6 | display: flex; 7 | flex-wrap: wrap; 8 | .c-image-upload { 9 | margin-inline-end: 10px; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /public/components/common/form/RadioButton.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-radiobutton input { 4 | margin: 0px; 5 | appearance: none; 6 | border: 1px solid var(--colors-gray-300); 7 | height: sizing(4); 8 | width: sizing(4); 9 | color: var(--colors-primary-base); 10 | border-radius: get("border.radius.full"); 11 | -webkit-appearance: none; 12 | appearance: none; 13 | 14 | &:checked { 15 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); 16 | } 17 | 18 | &:checked { 19 | border-color: transparent; 20 | background-color: currentColor; 21 | background-size: 100% 100%; 22 | background-position: center; 23 | background-repeat: no-repeat; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /public/components/common/form/Select.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-select { 4 | background-color: var(--colors-white); 5 | color: var(--colors-black); 6 | width: 100%; 7 | line-height: get("font.size.xl"); 8 | padding: spacing(2); 9 | border: 1px solid var(--colors-gray-300); 10 | border-radius: get("border.radius.medium"); 11 | appearance: none; 12 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); 13 | background-position: right 0.5rem center; 14 | background-repeat: no-repeat; 15 | background-size: 1.5em 1.5em; 16 | -webkit-appearance: none; 17 | appearance: none; 18 | 19 | &:disabled { 20 | @include disabled(); 21 | } 22 | 23 | &--error { 24 | border-color: var(--colors-red-600); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /public/components/common/form/TextArea.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-textarea { 4 | background-color: var(--colors-white); 5 | color: var(--colors-black); 6 | width: 100%; 7 | line-height: get("font.size.xl"); 8 | padding: spacing(2); 9 | resize: none; 10 | border: 1px solid var(--colors-gray-300); 11 | border-radius: get("border.radius.medium"); 12 | -webkit-appearance: none; 13 | appearance: none; 14 | 15 | &:disabled { 16 | @include disabled(); 17 | } 18 | &--error { 19 | border-color: var(--colors-red-600); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /public/components/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./ErrorBoundary" 2 | export * from "./ThemeSwitcher" 3 | export * from "./ShowPostResponse" 4 | export * from "./ShowPostStatus" 5 | export * from "./ShowTag" 6 | export * from "./Header" 7 | export * from "./SignInModal" 8 | export * from "./VoteCounter" 9 | export * from "./NotificationIndicator" 10 | export * from "./UserMenu" 11 | export * from "./Reactions" 12 | export * from "./ReadOnlyNotice" 13 | export * from "./common" 14 | -------------------------------------------------------------------------------- /public/components/layout/Divider.tsx: -------------------------------------------------------------------------------- 1 | import "./Divider.scss" 2 | 3 | import React from "react" 4 | import { Trans } from "@lingui/react/macro" 5 | 6 | export const Divider = () => { 7 | return ( 8 |
9 | OR 10 |
11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /public/components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | export * from "./Stack" 2 | export * from "./Divider" 3 | -------------------------------------------------------------------------------- /public/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./use-timeout" 2 | export * from "./use-fider" 3 | export * from "./use-script" 4 | export * from "./use-cache" 5 | -------------------------------------------------------------------------------- /public/hooks/use-cache.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, useEffect, useState } from "react" 2 | 3 | const isClient = typeof window !== "undefined" 4 | 5 | export function useCache(key: string, defaultValue: string): [string, Dispatch] { 6 | const [value, setValue] = useState(defaultValue) 7 | 8 | const setCachedValue = (newValue: string) => { 9 | if (isClient && window.sessionStorage) { 10 | window.sessionStorage.setItem(key, newValue) 11 | } 12 | setValue(newValue) 13 | } 14 | 15 | useEffect(() => { 16 | if (isClient) { 17 | const cachedValue = window.sessionStorage?.getItem(key) 18 | if (cachedValue) { 19 | setValue(cachedValue) 20 | } 21 | } 22 | }, [key, setValue]) 23 | 24 | return [value, setCachedValue] 25 | } 26 | -------------------------------------------------------------------------------- /public/hooks/use-fider.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from "react" 2 | import { FiderContext } from "@fider/services" 3 | 4 | export const useFider = () => useContext(FiderContext) 5 | -------------------------------------------------------------------------------- /public/hooks/use-timeout.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect } from "react" 2 | 3 | type CallbackFunction = () => void 4 | 5 | export function useTimeout(callback: CallbackFunction, delay: number) { 6 | const savedCallback = useRef() 7 | 8 | useEffect(() => { 9 | savedCallback.current = callback 10 | }) 11 | 12 | useEffect(() => { 13 | function tick() { 14 | if (savedCallback.current) { 15 | savedCallback.current() 16 | } 17 | } 18 | const timer = window.setTimeout(tick, delay) 19 | return function cleanup() { 20 | window.clearTimeout(timer) 21 | } 22 | }, [delay]) 23 | } 24 | -------------------------------------------------------------------------------- /public/jest.assets.ts: -------------------------------------------------------------------------------- 1 | const stub = {} 2 | export default stub 3 | -------------------------------------------------------------------------------- /public/jest.setup.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | // defines DOM related expect methods 4 | import "@testing-library/jest-dom/extend-expect" 5 | 6 | // Mock for LinguiJS so we don't need to setup i18n on each test 7 | jest.mock("@lingui/react", () => ({ 8 | Trans: function TransMock({ children }: { children: React.ReactNode }) { 9 | return <>{children} 10 | }, 11 | 12 | t: function tMock(id: string): string { 13 | return id 14 | }, 15 | 16 | Plural: function PluralMock({ value, one, other }: { value: number; one: React.ReactNode; other: React.ReactNode }) { 17 | return <>{value > 1 ? other : one} 18 | }, 19 | })) 20 | -------------------------------------------------------------------------------- /public/models/billing.ts: -------------------------------------------------------------------------------- 1 | export enum BillingStatus { 2 | Trial = 1, 3 | Active = 2, 4 | Cancelled = 3, 5 | FreeForever = 4, 6 | OpenCollective = 5, 7 | } 8 | -------------------------------------------------------------------------------- /public/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./post" 2 | export * from "./identity" 3 | export * from "./settings" 4 | export * from "./billing" 5 | export * from "./notification" 6 | export * from "./webhook" 7 | -------------------------------------------------------------------------------- /public/models/notification.ts: -------------------------------------------------------------------------------- 1 | export interface Notification { 2 | id: number 3 | title: string 4 | link: string 5 | read: boolean 6 | createdAt: string 7 | authorName: string 8 | avatarURL: string 9 | } 10 | -------------------------------------------------------------------------------- /public/pages/Administration/components/AdminBasePage.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-admin-basepage { 4 | display: grid; 5 | grid-template-columns: 1fr; 6 | gap: spacing(4); 7 | 8 | @include media("lg") { 9 | grid-template-columns: 1fr 4fr 1fr; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /public/pages/Administration/components/SideMenu.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-side-menu { 4 | &__item { 5 | padding: spacing(4); 6 | border-bottom: 1px solid var(--colors-gray-200); 7 | color: var(--colors-gray-900); 8 | 9 | &:hover { 10 | background-color: var(--colors-gray-100); 11 | } 12 | 13 | &:last-child { 14 | border-bottom: none; 15 | } 16 | 17 | &--active { 18 | color: var(--colors-primary-base); 19 | border-inline-start: 2px solid var(--colors-primary-base); 20 | font-weight: 600; 21 | background-color: var(--colors-white); 22 | &:hover { 23 | color: var(--colors-primary-base); 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /public/pages/Administration/components/webhook/WebhookFailInfo.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-webhook-failinfo { 4 | pre { 5 | text-align: left; 6 | white-space: pre-wrap; 7 | font-size: get("font.size.sm"); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /public/pages/Administration/components/webhook/WebhookForm.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-webhook-form { 4 | &__content { 5 | font-family: $font-code; 6 | } 7 | 8 | &__preview { 9 | pre { 10 | white-space: pre-wrap; 11 | font-size: get("font.size.sm"); 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /public/pages/Administration/components/webhook/WebhookListItem.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-webhook-listitem { 4 | &__icon { 5 | vertical-align: middle; 6 | 7 | &--enabled { 8 | color: var(--colors-green-500); 9 | } 10 | 11 | &--disabled { 12 | color: var(--colors-yellow-500); 13 | } 14 | 15 | &--failed { 16 | color: var(--colors-red-500); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /public/pages/DesignSystem/DesignSystem.page.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | #p-ui-toolkit { 4 | .color { 5 | width: 40px; 6 | height: 40px; 7 | display: inline-block; 8 | margin: 2px; 9 | border-radius: 4px; 10 | 11 | @each $name, $shades in $colors { 12 | @if type-of($shades) == "map" { 13 | @each $shade, $color in $shades { 14 | &.#{$name}-#{$shade} { 15 | background-color: $color; 16 | } 17 | } 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /public/pages/DesignSystem/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./DesignSystem.page" 2 | -------------------------------------------------------------------------------- /public/pages/Error/Error401.page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Trans } from "@lingui/react/macro" 3 | import { ErrorPageWrapper } from "./components/ErrorPageWrapper" 4 | 5 | const Error401 = () => { 6 | return ( 7 | 8 |

9 | Unauthorized 10 |

11 |

12 | You need to sign in before accessing this page. 13 |

14 |
15 | ) 16 | } 17 | 18 | export default Error401 19 | -------------------------------------------------------------------------------- /public/pages/Error/Error403.page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Trans } from "@lingui/react/macro" 3 | import { ErrorPageWrapper } from "./components/ErrorPageWrapper" 4 | 5 | const Error403 = () => { 6 | return ( 7 | 8 |

9 | Forbidden 10 |

11 |

12 | You are not authorized to view this page. 13 |

14 |
15 | ) 16 | } 17 | 18 | export default Error403 19 | -------------------------------------------------------------------------------- /public/pages/Error/Error404.page.tsx: -------------------------------------------------------------------------------- 1 | import { Trans } from "@lingui/react/macro" 2 | import React from "react" 3 | import { ErrorPageWrapper } from "./components/ErrorPageWrapper" 4 | 5 | const Error404 = () => { 6 | return ( 7 | 8 |

9 | Page not found 10 |

11 |

12 | The link you clicked may be broken or the page may have been removed. 13 |

14 |
15 | ) 16 | } 17 | 18 | export default Error404 19 | -------------------------------------------------------------------------------- /public/pages/Error/Error410.page.tsx: -------------------------------------------------------------------------------- 1 | import { Trans } from "@lingui/react/macro" 2 | import React from "react" 3 | import { ErrorPageWrapper } from "./components/ErrorPageWrapper" 4 | 5 | const Error410 = () => { 6 | return ( 7 | 8 |

9 | Expired 10 |

11 |

12 | The link you clicked has expired. 13 |

14 |
15 | ) 16 | } 17 | 18 | export default Error410 19 | -------------------------------------------------------------------------------- /public/pages/Error/Error500.page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { ErrorPageWrapper } from "./components/ErrorPageWrapper" 3 | import { Trans } from "@lingui/react/macro" 4 | 5 | const Error500 = () => { 6 | return ( 7 | 8 |

9 | Shoot! Well, this is unexpected… 10 |

11 |

12 | An error has occurred and we're working to fix the problem! We’ll be up and running shortly. 13 |

14 |
15 | ) 16 | } 17 | 18 | export default Error500 19 | -------------------------------------------------------------------------------- /public/pages/Error/Maintenance.page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { ErrorPageWrapper } from "./components/ErrorPageWrapper" 3 | 4 | interface MaintenanceProps { 5 | message: string 6 | until?: string 7 | } 8 | 9 | const Maintenance = (props: MaintenanceProps) => { 10 | return ( 11 | 12 |

UNDER MAINTENANCE

13 |

{props.message}

14 | {props.until ? ( 15 |

16 | We'll be back at {props.until}. 17 |

18 | ) : ( 19 |

We'll be back soon.

20 | )} 21 |
22 | ) 23 | } 24 | 25 | export default Maintenance 26 | -------------------------------------------------------------------------------- /public/pages/Error/NotInvited.page.tsx: -------------------------------------------------------------------------------- 1 | import { Trans } from "@lingui/react/macro" 2 | import React from "react" 3 | import { ErrorPageWrapper } from "./components/ErrorPageWrapper" 4 | 5 | const NotInvited = () => { 6 | return ( 7 | 8 |

9 | Not Invited 10 |

11 |

12 | We could not find an account for your email address. 13 |

14 |
15 | ) 16 | } 17 | 18 | export default NotInvited 19 | -------------------------------------------------------------------------------- /public/pages/Error/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Error.page" 2 | -------------------------------------------------------------------------------- /public/pages/Home/Home.page.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | #p-home { 4 | display: grid; 5 | grid-template-columns: 1fr; 6 | column-gap: 0; 7 | row-gap: spacing(6); 8 | 9 | @include media("lg") { 10 | grid-template-columns: 1fr 1fr 1fr; 11 | column-gap: spacing(6); 12 | row-gap: 0; 13 | } 14 | 15 | .p-home { 16 | &__welcome-col { 17 | > :first-child { 18 | background-color: var(--colors-white); 19 | border-radius: get("border.radius.large"); 20 | border: 1px solid var(--colors-gray-200); 21 | 22 | @include media("lg") { 23 | } 24 | } 25 | } 26 | 27 | &__posts-col { 28 | background-color: var(--colors-white); 29 | border-radius: get("border.radius.large"); 30 | border: 1px solid var(--colors-gray-200); 31 | 32 | @include media("lg") { 33 | } 34 | } 35 | 36 | &__posts-col { 37 | grid-column: span 2 / span 2; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /public/pages/Home/components/PostsContainer.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-posts-container { 4 | &__header { 5 | display: grid; 6 | grid-template-columns: 1fr 1fr 1fr 1fr; 7 | row-gap: spacing(3); 8 | } 9 | 10 | &__filter-col { 11 | grid-column: 1 / -1; 12 | display: flex; 13 | flex-direction: row; 14 | & > * + * { 15 | margin-inline-start: 0; 16 | } 17 | 18 | @include media("md") { 19 | grid-column: span 3 / span 3; 20 | flex-direction: row; 21 | & > * + * { 22 | margin-inline-start: spacing(1); 23 | } 24 | } 25 | } 26 | 27 | &__search-col { 28 | grid-column: 4; 29 | &:first-child { 30 | grid-column: 1 / -1; 31 | } 32 | 33 | @include media("sm") { 34 | grid-column: 1 / -1; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /public/pages/Home/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Home.page" 2 | -------------------------------------------------------------------------------- /public/pages/Legal/Legal.page.tsx: -------------------------------------------------------------------------------- 1 | import { Markdown } from "@fider/components" 2 | import React from "react" 3 | import "./Legal.page.scss" 4 | 5 | export interface LegalPageProps { 6 | content: string 7 | } 8 | 9 | const LegalPage = (props: LegalPageProps) => { 10 | return ( 11 | 14 | ) 15 | } 16 | 17 | export default LegalPage 18 | -------------------------------------------------------------------------------- /public/pages/Legal/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Legal.page" 2 | -------------------------------------------------------------------------------- /public/pages/MyNotifications/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./MyNotifications.page" 2 | -------------------------------------------------------------------------------- /public/pages/MySettings/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./MySettings.page" 2 | -------------------------------------------------------------------------------- /public/pages/OAuthEcho/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./OAuthEcho.page" 2 | -------------------------------------------------------------------------------- /public/pages/ShowPost/components/MentionSelector.scss: -------------------------------------------------------------------------------- 1 | @import "~@fider/assets/styles/variables.scss"; 2 | 3 | .c-mention-selector { 4 | position: absolute; 5 | z-index: 1000; 6 | width: 200px; 7 | border-radius: get("border.radius.small"); 8 | background-color: var(--colors-white); 9 | border: 1px solid var(--colors-gray-200); 10 | padding: spacing(2); 11 | } 12 | -------------------------------------------------------------------------------- /public/pages/ShowPost/components/MentionSelector.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import "./MentionSelector.scss" 4 | 5 | const MentionSelector: React.FC<{ names: string[]; cursorPosition: { top: number; left: number } }> = ({ names, cursorPosition }) => { 6 | return ( 7 |
14 | {names.map((name, index) => ( 15 |
16 | {name} 17 |
18 | ))} 19 |
20 | ) 21 | } 22 | export default MentionSelector 23 | -------------------------------------------------------------------------------- /public/pages/ShowPost/components/PostStatus.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/getfider/fider/3f8bfae38a1547ff5cf0ebd4125ff942994e5898/public/pages/ShowPost/components/PostStatus.tsx -------------------------------------------------------------------------------- /public/pages/ShowPost/components/TagListItem.tsx: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Tag } from "@fider/models" 3 | import { Icon, ShowTag } from "@fider/components" 4 | import IconCheck from "@fider/assets/images/heroicons-check.svg" 5 | import { HStack } from "@fider/components/layout" 6 | 7 | interface TagListItemProps { 8 | tag: Tag 9 | assigned: boolean 10 | onClick: (tag: Tag) => void 11 | } 12 | 13 | export const TagListItem = (props: TagListItemProps) => { 14 | const onClick = () => { 15 | props.onClick(props.tag) 16 | } 17 | 18 | return ( 19 | 20 | 21 | 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /public/pages/ShowPost/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./ShowPost.page" 2 | -------------------------------------------------------------------------------- /public/pages/SignIn/CompleteSignInProfile.page.scss: -------------------------------------------------------------------------------- 1 | #p-complete-profile { 2 | display: flex; 3 | flex-direction: row; 4 | justify-content: center; 5 | align-items: center; 6 | height: 80vh; 7 | } 8 | -------------------------------------------------------------------------------- /public/pages/SignIn/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./SignIn.page" 2 | export * from "./CompleteSignInProfile.page" 3 | -------------------------------------------------------------------------------- /public/pages/SignUp/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./SignUp.page" 2 | -------------------------------------------------------------------------------- /public/services/actions/billing.ts: -------------------------------------------------------------------------------- 1 | import { http, Result } from "../http" 2 | 3 | interface CheckoutPageLink { 4 | url: string 5 | } 6 | 7 | export const generateCheckoutLink = async (planId: string): Promise> => { 8 | return await http.post("/_api/billing/checkout-link", { planId }) 9 | } 10 | -------------------------------------------------------------------------------- /public/services/actions/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./user" 2 | export * from "./tag" 3 | export * from "./post" 4 | export * from "./tenant" 5 | export * from "./notification" 6 | export * from "./invite" 7 | export * from "./infra" 8 | export * from "./webhook" 9 | export * from "./billing" 10 | -------------------------------------------------------------------------------- /public/services/actions/invite.ts: -------------------------------------------------------------------------------- 1 | import { http, Result } from "@fider/services" 2 | 3 | export const sendInvites = async (subject: string, message: string, recipients: string[]): Promise => { 4 | return http.post("/api/v1/invitations/send", { subject, message, recipients }).then(http.event("invite", "send")) 5 | } 6 | 7 | export const sendSampleInvite = async (subject: string, message: string): Promise => { 8 | return http.post("/api/v1/invitations/sample", { subject, message }).then(http.event("invite", "sample")) 9 | } 10 | -------------------------------------------------------------------------------- /public/services/actions/notification.ts: -------------------------------------------------------------------------------- 1 | import { http, Result } from "@fider/services" 2 | import { Notification } from "@fider/models" 3 | 4 | export const getTotalUnreadNotifications = async (): Promise> => { 5 | return http.get<{ total: number }>("/_api/notifications/unread/total").then((result) => { 6 | return { 7 | ok: result.ok, 8 | error: result.error, 9 | data: result.data ? result.data.total : 0, 10 | } 11 | }) 12 | } 13 | 14 | export const getAllNotifications = async (): Promise> => { 15 | return http.get("/_api/notifications/unread") 16 | } 17 | 18 | export const markAllAsRead = async (): Promise => { 19 | return await http.post("/_api/notifications/read-all") 20 | } 21 | -------------------------------------------------------------------------------- /public/services/actions/user.ts: -------------------------------------------------------------------------------- 1 | import { http, Result } from "@fider/services/http" 2 | import { UserSettings, UserAvatarType, ImageUpload } from "@fider/models" 3 | 4 | interface UpdateUserSettings { 5 | name: string 6 | avatar?: ImageUpload 7 | avatarType: UserAvatarType 8 | settings: UserSettings 9 | } 10 | 11 | export const updateUserSettings = async (request: UpdateUserSettings): Promise => { 12 | return await http.post("/_api/user/settings", request) 13 | } 14 | 15 | export const changeUserEmail = async (email: string): Promise => { 16 | return await http.post("/_api/user/change-email", { 17 | email, 18 | }) 19 | } 20 | 21 | export const deleteCurrentAccount = async (): Promise => { 22 | return await http.delete("/_api/user") 23 | } 24 | 25 | export const regenerateAPIKey = async (): Promise> => { 26 | return await http.post<{ apiKey: string }>("/_api/user/regenerate-apikey") 27 | } 28 | -------------------------------------------------------------------------------- /public/services/analytics.ts: -------------------------------------------------------------------------------- 1 | export const analytics = { 2 | event: (eventCategory: string, eventAction: string): void => { 3 | if (window.ga) { 4 | window.ga("send", "event", { 5 | eventCategory, 6 | eventAction, 7 | }) 8 | } 9 | }, 10 | error: (err?: Error): void => { 11 | if (window.ga) { 12 | window.ga("send", "exception", { 13 | exDescription: err ? err.stack : "", 14 | exFatal: false, 15 | }) 16 | } 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /public/services/cache.spec.ts: -------------------------------------------------------------------------------- 1 | import { cache } from "./cache" 2 | 3 | test("cache starts empty", () => { 4 | const value = cache.local.get("my-key") 5 | expect(value).toBeNull() 6 | 7 | expect(cache.local.has("my-key")).toBeFalsy() 8 | }) 9 | 10 | test("can set, remove and get from cache", () => { 11 | cache.local.set("my-key", "Hello World") 12 | const value = cache.local.get("my-key") 13 | expect(value).toBe("Hello World") 14 | expect(cache.local.has("my-key")).toBeTruthy() 15 | 16 | cache.local.remove("my-key") 17 | const newValue = cache.local.get("my-key") 18 | expect(newValue).toBeNull() 19 | }) 20 | -------------------------------------------------------------------------------- /public/services/device.ts: -------------------------------------------------------------------------------- 1 | export const isTouch = (): boolean => { 2 | return "ontouchstart" in window || navigator.maxTouchPoints > 0 3 | } 4 | -------------------------------------------------------------------------------- /public/services/i18n.ts: -------------------------------------------------------------------------------- 1 | import { i18n } from "@lingui/core" 2 | 3 | export function activateI18NSync(locale: string, messages?: any) { 4 | i18n.load(locale, messages) 5 | i18n.activate(locale) 6 | return i18n 7 | } 8 | 9 | export async function activateI18N(locale: string) { 10 | try { 11 | const content = await import( 12 | /* webpackChunkName: "locale-[request]" */ 13 | `@locale/${locale}/client.json` 14 | ) 15 | return activateI18NSync(locale, content.messages) 16 | } catch (err) { 17 | console.error(err) 18 | return activateI18NSync(locale) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /public/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./http" 2 | export * from "./cache" 3 | export * from "./analytics" 4 | export * from "./fider" 5 | export * from "./jwt" 6 | export * from "./utils" 7 | export * from "./i18n" 8 | import * as markdown from "./markdown" 9 | import * as notify from "./notify" 10 | import * as querystring from "./querystring" 11 | import * as device from "./device" 12 | import * as actions from "./actions" 13 | import navigator from "./navigator" 14 | export { actions, querystring, navigator, device, notify, markdown } 15 | -------------------------------------------------------------------------------- /public/services/jwt.spec.ts: -------------------------------------------------------------------------------- 1 | import { jwt } from "./jwt" 2 | ;[ 3 | { 4 | expected: { 5 | sub: "1234567890", 6 | name: "John Snow", 7 | age: "30", 8 | iat: 1516239022, 9 | }, 10 | token: 11 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gU25vdyIsImFnZSI6IjMwIiwiaWF0IjoxNTE2MjM5MDIyfQ.A4e171Ry70APUJ2a9uo9G9Aju9G08AJB_Cr9B9ivX-o", 12 | }, 13 | { 14 | expected: undefined, 15 | token: "wrong", 16 | }, 17 | ].forEach((x) => { 18 | test(`decode('${x.token}') should be ${x.expected}`, () => { 19 | const result = jwt.decode(x.token) 20 | expect(result).toEqual(x.expected) 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /public/services/jwt.ts: -------------------------------------------------------------------------------- 1 | export const jwt = { 2 | decode: (token: string): any => { 3 | if (token) { 4 | const segments = token.split(".") 5 | try { 6 | return JSON.parse(window.atob(segments[1])) 7 | } catch { 8 | return undefined 9 | } 10 | } 11 | }, 12 | } 13 | -------------------------------------------------------------------------------- /public/services/navigator.ts: -------------------------------------------------------------------------------- 1 | import { Fider } from "@fider/services" 2 | 3 | const navigator = { 4 | url: () => { 5 | return window.location.href 6 | }, 7 | goHome: () => { 8 | window.location.href = "/" 9 | }, 10 | goTo: (url: string) => { 11 | const isEqual = window.location.href === url || window.location.pathname === url 12 | if (!isEqual) { 13 | window.location.href = url 14 | } 15 | }, 16 | replaceState: (path: string): void => { 17 | if (history.replaceState !== undefined) { 18 | const newURL = Fider.settings.baseURL + path 19 | window.history.replaceState({ path: newURL }, "", newURL) 20 | } 21 | }, 22 | } 23 | 24 | export default navigator 25 | -------------------------------------------------------------------------------- /public/services/notify.ts: -------------------------------------------------------------------------------- 1 | const toastify = () => import(/* webpackChunkName: "toastify" */ "./toastify") 2 | 3 | export const success = (content: string | JSX.Element) => { 4 | return toastify().then((toast) => { 5 | toast.success(content) 6 | }) 7 | } 8 | 9 | export const error = (content: string | JSX.Element) => { 10 | return toastify().then((toast) => { 11 | toast.error(content) 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /public/services/testing/fider.ts: -------------------------------------------------------------------------------- 1 | import { Fider } from "@fider/services" 2 | import { FiderImpl } from "../fider" 3 | 4 | export const fiderMock = { 5 | notAuthenticated: (): FiderImpl => { 6 | return Fider.initialize({ 7 | settings: { 8 | environment: "development", 9 | oauth: [], 10 | }, 11 | tenant: {}, 12 | user: undefined, 13 | }) 14 | }, 15 | authenticated: (): FiderImpl => { 16 | return Fider.initialize({ 17 | settings: { 18 | environment: "development", 19 | oauth: [], 20 | }, 21 | tenant: {}, 22 | user: { 23 | name: "Jon Snow", 24 | }, 25 | }) 26 | }, 27 | } 28 | -------------------------------------------------------------------------------- /public/services/testing/http.ts: -------------------------------------------------------------------------------- 1 | import { http } from "@fider/services" 2 | 3 | const createOkMock = () => { 4 | return jest.fn(() => { 5 | return Promise.resolve({ 6 | ok: true, 7 | data: null as any, 8 | }) 9 | }) 10 | } 11 | 12 | export const httpMock = { 13 | alwaysOk: () => { 14 | http.get = createOkMock() 15 | http.post = createOkMock() 16 | http.put = createOkMock() 17 | http.delete = createOkMock() 18 | return http 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /public/services/testing/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./http" 2 | export * from "./fider" 3 | export * from "./modal" 4 | -------------------------------------------------------------------------------- /public/services/testing/modal.ts: -------------------------------------------------------------------------------- 1 | export const setupModalRoot = () => { 2 | let portalRoot = document.getElementById("root-modal") 3 | if (portalRoot) { 4 | document.body.removeChild(portalRoot) 5 | } 6 | 7 | portalRoot = document.createElement("div") 8 | portalRoot.setAttribute("id", "root-modal") 9 | document.body.appendChild(portalRoot) 10 | } 11 | -------------------------------------------------------------------------------- /robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: /_api/ 3 | Disallow: /api/v1/ 4 | Disallow: /admin/ 5 | Disallow: /oauth/ 6 | Disallow: /terms 7 | Disallow: /privacy 8 | Disallow: /_design -------------------------------------------------------------------------------- /scripts/git-prune-local.sh: -------------------------------------------------------------------------------- 1 | # https://medium.com/@kcmueller/delete-local-git-branches-that-were-deleted-on-remote-repository-b596b71b530c 2 | git fetch -p 3 | git branch -vv | grep ' gone]' | awk '{print $1}' | xargs git branch -D -------------------------------------------------------------------------------- /scripts/kill-dev.sh: -------------------------------------------------------------------------------- 1 | kill $(lsof -t -i :3000) -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | // +build tools 2 | 3 | package tools 4 | 5 | import ( 6 | _ "github.com/cosmtrek/air" 7 | _ "github.com/golangci/golangci-lint/cmd/golangci-lint" 8 | _ "github.com/joho/godotenv/cmd/godotenv" 9 | ) 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "incremental": true, 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "allowSyntheticDefaultImports": true, 8 | "target": "es6", 9 | "strict": true, 10 | "jsx": "react", 11 | "lib": ["dom", "es2015.promise", "es6", "ES2020.Intl"], 12 | "baseUrl": ".", 13 | "noEmit": true, 14 | "noUnusedLocals": true, 15 | "paths": { 16 | "@fider/*": ["./public/*"], 17 | "@locale/*": ["./locale/*"] 18 | } 19 | }, 20 | "include": ["./public/**/*", "./locale/**/*", "index.d.ts", "./e2e/**/*"] 21 | } 22 | -------------------------------------------------------------------------------- /views/email/change_emailaddress_email.html: -------------------------------------------------------------------------------- 1 | {{define "subject"}}{{ "email.change_emailaddress.subject" | translate }}{{end}} 2 | 3 | {{define "body"}} 4 | 5 | 6 |

{{ translate "email.greetings_name" (dict "name" .name) }}

7 |

{{ translate "email.change_emailaddress.request" (dict "oldEmail" .oldEmail "newEmail" .newEmail) }}

8 |

{{ "email.operation_confirmation" | translate }}

9 |

{{ .link | html }}

10 | 11 | 12 | {{end}} -------------------------------------------------------------------------------- /views/email/change_status.html: -------------------------------------------------------------------------------- 1 | {{define "subject"}}[{{ .siteName }}] {{ .title }}{{end}} 2 | 3 | {{define "body"}} 4 | 5 | 6 |

7 | {{ if .duplicate }} 8 | {{ translate "email.change_status.duplicate" (dict "title" (.title | stripHtml) "postLink" .postLink "duplicate" .duplicate) | html }} 9 | {{ else }} 10 | {{ translate "email.change_status.others" (dict "title" (.title | stripHtml) "postLink" .postLink "status" (.status | lower)) | html }} 11 | {{ end }} 12 |

13 | {{ .content }} 14 |

15 | —
16 | {{ translate "email.footer.subscription_notice" (dict "view" .view "unsubscribe" .unsubscribe "change" .change) | html }} 17 |

18 | 19 | 20 | {{end}} 21 | 22 | -------------------------------------------------------------------------------- /views/email/delete_post.html: -------------------------------------------------------------------------------- 1 | {{define "subject"}}[{{ .siteName }}] {{ .title }}{{end}} 2 | 3 | {{define "body"}} 4 | 5 | 6 |

7 | {{ translate "email.delete_post.text" (dict "title" (.title | stripHtml)) | html }} 8 |

9 | {{ .content }} 10 |

11 | —
12 | {{ translate "email.footer.subscription_notice2" (dict "change" .change) | html }} 13 |

14 | 15 | 16 | {{end}} -------------------------------------------------------------------------------- /views/email/echo_test.html: -------------------------------------------------------------------------------- 1 | {{define "subject"}}Message to: {{ .name }}{{end}} 2 | 3 | {{define "body"}} 4 | Hello World {{ .name }}! 5 | {{end}} -------------------------------------------------------------------------------- /views/email/invite_email.html: -------------------------------------------------------------------------------- 1 | {{define "subject"}}{{ .subject }}{{end}} 2 | 3 | {{define "body"}} 4 | 5 | {{ .message }} 6 | 7 | {{end}} -------------------------------------------------------------------------------- /views/email/new_comment.html: -------------------------------------------------------------------------------- 1 | {{define "subject"}}[{{ .siteName }}] {{ .title }}{{end}} 2 | 3 | {{define "body"}} 4 | 5 | 6 |

7 | {{ translate .messageLocaleString (dict "userName" .userName "title" (.title | stripHtml) "postLink" .postLink) | html }} 8 |

9 | {{ .content }} 10 |

11 | —
12 | {{ translate "email.footer.subscription_notice" (dict "view" .view "unsubscribe" .unsubscribe "change" .change) | html }} 13 |

14 | 15 | 16 | {{end}} -------------------------------------------------------------------------------- /views/email/new_post.html: -------------------------------------------------------------------------------- 1 | {{define "subject"}}[{{ .siteName }}] {{ .title }}{{end}} 2 | 3 | {{define "body"}} 4 | 5 | 6 |

7 | {{ translate "email.new_post.text" (dict "userName" (.userName | stripHtml) "title" (.title | stripHtml) "postLink" .postLink) | html }} 8 |

9 | {{ .content }} 10 |

11 | —
12 | {{ translate "email.footer.subscription_notice3" (dict "view" .view "change" .change) | html }} 13 |

14 | 15 | 16 | {{end}} -------------------------------------------------------------------------------- /views/email/signin_email.html: -------------------------------------------------------------------------------- 1 | {{define "subject"}}{{ translate "email.signin_email.subject" (dict "siteName" .siteName) }}{{end}} 2 | 3 | {{define "body"}} 4 | 5 | 6 |

{{ "email.greetings" | translate }}

7 |

{{ "email.signin_email.text" | translate }}

8 |

{{ translate "email.signin_email.confirmation" (dict "siteName" (.siteName | stripHtml)) | html }}

9 |

{{ .link | html }}

10 | 11 | 12 | {{end}} -------------------------------------------------------------------------------- /views/email/signup_email.html: -------------------------------------------------------------------------------- 1 | {{define "subject"}}{{ "email.signup_email.subject" | translate }}{{end}} 2 | 3 | {{define "body"}} 4 | 5 | 6 |

{{ "email.greetings" | translate }}

7 |

{{ "email.signup_email.text" | translate }}

8 |

{{ "email.signup_email.confirmation" | translate }}

9 |

{{ .link | html }}

10 | 11 | 12 | {{end}} -------------------------------------------------------------------------------- /views/ssr.html: -------------------------------------------------------------------------------- 1 | {{define "head"}} 2 | {{range $asset := .private.preloadAssets}} 3 | {{if $asset}} 4 | {{range $asset.CSS}}{{end}} 5 | {{end}} 6 | {{end}} 7 | {{end}} 8 | 9 | {{define "server-data"}}{{end}} 10 | 11 | {{define "content"}} 12 |
{{ .public.props.html }}
13 | {{end}} 14 | 15 | {{define "end-of-body"}}{{end}} --------------------------------------------------------------------------------