diff --git a/.example.env b/.example.env index 737c02333..c8ed924cd 100644 --- a/.example.env +++ b/.example.env @@ -33,3 +33,13 @@ EMAIL_SMTP_HOST=localhost EMAIL_SMTP_PORT=1025 EMAIL_SMTP_USERNAME= EMAIL_SMTP_PASSWORD= + +# Commercial License (Optional for self-hosted, Required for hosted multi-tenant) +# LICENSE_MASTER_SECRET: Only required for hosted Fider instances that sell Pro subscriptions +# - Used to generate and validate license keys for Pro customers +# - Not needed for self-hosted unless you want to validate a COMMERCIAL_KEY +# COMMERCIAL_KEY: For self-hosted Fider, set this to enable commercial features (content moderation) +# - Leave blank to run Fider without commercial features (free/open-source mode) +# - If you have a commercial license, set it here +# LICENSE_MASTER_SECRET=your-secret-key-here-change-in-production +# COMMERCIAL_KEY=FIDER-COMMERCIAL-123-1638360000-abc123... diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 455b39044..14025dfdb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -69,7 +69,7 @@ jobs: - name: make test-server run: | mkdir ./dist - make test-server + make test-server SHORT=false env: BLOB_STORAGE_S3_ENDPOINT_URL: http://localhost:9000 DATABASE_URL: postgres://fider_ci:fider_ci_pw@localhost:5432/fider_ci?sslmode=disable diff --git a/.github/workflows/locale.yml b/.github/workflows/locale.yml index 43180bbb8..5812c171e 100644 --- a/.github/workflows/locale.yml +++ b/.github/workflows/locale.yml @@ -1,5 +1,6 @@ name: Auto-Translate Missing Keys +# Workflow automatic triggers disabled - only manual trigger is available on: workflow_dispatch: inputs: @@ -8,11 +9,10 @@ on: required: false default: "false" - push: - branches: - - main - paths: - - "locale/en/**.json" + # Automatic trigger temporarily disabled + # push: + # paths: + # - "locale/en/**.json" jobs: translate: diff --git a/.gitignore b/.gitignore index 3cdeffa09..d4642005a 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ tsconfig.tsbuildinfo etc/*.pem fider_schema.sql fider.sql +.zed/debug.json +WARP.md diff --git a/.test.env b/.test.env index f5d61a25f..464916b20 100644 --- a/.test.env +++ b/.test.env @@ -40,3 +40,5 @@ EMAIL_MAILGUN_DOMAIN=mydomain.com USER_LIST_ENABLED=true USER_LIST_APIKEY=abcdefg + +LICENSE_MASTER_SECRET=test_master_secret_for_license_generation diff --git a/CLAUDE.md b/CLAUDE.md index 055e41378..e556319bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ The application includes pluggable services for: - **Email**: SMTP, Mailgun, AWS SES - **Blob Storage**: Filesystem, S3, SQL - **OAuth**: Custom providers, GitHub, Google, etc. -- **Billing**: Paddle integration (optional) +- **Billing**: Stripe integration (optional) - **Webhooks**: Outbound event notifications ## Development Setup Requirements @@ -180,7 +180,7 @@ Fider uses BEM methodology combined with utility classes: - **`c-__`** - Element classes (e.g., `c-toggle__label`) - **`c---`** - State modifiers (e.g., `c-toggle--checked`) - **`is-`, `has-`** - Global state modifiers -- **Utility classes** - No prefix, used for common styling patterns. All utility classes are defined in public/assets/styles/utility/ +- **Utility classes** - No prefix, used for common styling patterns. All utility classes are defined in public/assets/styles/utility ### General Principles diff --git a/COMMERCIAL_MODERATION_PLAN.md b/COMMERCIAL_MODERATION_PLAN.md new file mode 100644 index 000000000..f00566085 --- /dev/null +++ b/COMMERCIAL_MODERATION_PLAN.md @@ -0,0 +1,213 @@ +# Commercial Content Moderation Restructure Plan + +## Overview + +Moving Fider's content moderation feature from open source (AGPL) to commercial licensing using an "open core" model. The commercial code will reside in a `commercial/` folder with a restrictive license, while the open source core provides infrastructure and gracefully degrades when commercial features aren't licensed. + +## Key Architectural Decisions + +### 1. Webpack Integration (Simple Solution) +- Update `webpack.config.js` to scan both folders: + ```js + paths: [ + ...glob.sync(`./public/**/*.{html,tsx}`, { nodir: true }), + ...glob.sync(`./commercial/**/*.{html,tsx}`, { nodir: true }) + ] + ``` +- Commercial code gets bundled but legal license prevents usage +- No complex conditional compilation needed + +### 2. Backend Package Separation Strategy + +**Handlers - Dynamic Route Registration** +- Open source: Register stub routes returning "upgrade" messages +- Commercial: Override routes with real handlers via dynamic registration +- License validation controls active routes + +**Services - Stub + Override Pattern** +- Open source: Register stub bus handlers returning "not licensed" errors +- Commercial: Override with real implementations via bus registration +- Same command/query types, different implementations + +## What Moves to Commercial (Strong Legal Protection) + +### Commercial Folder Structure +``` +commercial/ +├── LICENSE (restrictive commercial license) +├── pages/ +│ └── Administration/ +│ └── ContentModeration.page.tsx (~300 lines) +├── components/ +│ └── ModerationIndicator.tsx +├── handlers/ +│ ├── moderation.go (ModerationPage, GetModerationItems, GetModerationCount) +│ └── apiv1/ +│ ├── moderation.go (all API endpoints) +│ └── moderation_test.go +├── services/ +│ └── moderation.go (approve/decline business logic) +└── init.go (service/route registration) +``` + +### Frontend (Moves to Commercial) +- Complete moderation admin UI (ContentModeration.page.tsx + styles) +- Moderation indicator component showing pending counts +- All moderation-specific UI components + +### Backend (Moves to Commercial) +- All HTTP handlers (`ModerationPage`, `GetModerationItems`, `GetModerationCount`) +- All API endpoints (`/api/v1/admin/moderation/*` routes) +- Business logic implementations (approve/decline/verify/block functions) +- Route registrations for moderation endpoints +- Tests for commercial functionality + +## What Stays Open Source (Minimal Infrastructure) + +### Database/Models (Cannot Move - Bus System Dependency) +- `app/models/cmd/moderation.go` - Command type definitions +- `app/models/query/moderation.go` - Query type definitions +- Database schema, migrations (`is_moderation_enabled`, `is_approved` columns) +- Entity definitions (`ModerationItem` structs) + +### Settings Infrastructure +- Privacy settings toggle (shows "upgrade" message if unlicensed) +- Basic tenant property (`isModerationEnabled`) +- License validation service + +### Content Flow Logic +- Logic that marks content as needing approval +- Basic "content requires moderation" checks throughout codebase +- Content queuing when moderation is enabled + +### Locale Files +- All `moderation.*` translation strings (used by upgrade messages too) + +## Technical Implementation Details + +### 1. Handler Registration Pattern +```go +// Open source routes.go - stub routes +ui.Get("/admin/moderation", upgradeHandler("content-moderation")) +ui.Get("/_api/admin/moderation/items", upgradeHandler("content-moderation")) + +// Commercial init.go - overrides routes +func init() { + if license.IsCommercialFeatureEnabled("content-moderation") { + web.RegisterRoute("GET", "/admin/moderation", handlers.ModerationPage()) + web.RegisterRoute("GET", "/_api/admin/moderation/items", handlers.GetModerationItems()) + // ... all other moderation routes + } +} +``` + +### 2. Service Registration Pattern +```go +// Open source postgres.go - register stubs +func (s *Service) Init() { + bus.AddHandler(approvePostStub) // Returns "feature not licensed" + bus.AddHandler(declinePostStub) + bus.AddHandler(getModerationItemsStub) + // ... other stubs +} + +// Commercial service init - override with real handlers +func (cs *CommercialService) Init() { + if license.IsCommercialFeatureEnabled("content-moderation") { + bus.AddHandler(approvePost) // Real implementation + bus.AddHandler(declinePost) + bus.AddHandler(getModerationItems) + // ... other real handlers + } +} +``` + +### 3. License Validation Service +```go +// app/services/license.go +type LicenseService interface { + IsCommercialFeatureEnabled(feature string) bool +} + +// Implementation checks for valid commercial license +// Controls route registration and service overrides +``` + +## Implementation Phases + +### Phase 1: Setup Commercial Infrastructure +1. Create `commercial/` folder structure +2. Add restrictive LICENSE file to commercial folder +3. Update webpack.config.js to scan commercial folder +4. Create license validation service interface + +### Phase 2: Move Frontend Components +1. Move `ContentModeration.page.tsx` to `commercial/pages/Administration/` +2. Move `ModerationIndicator.tsx` to `commercial/components/` +3. Test that webpack builds both locations correctly +4. Add license checks in components to show upgrade messages + +### Phase 3: Implement Backend Service Separation +1. Create stub implementations for all moderation commands/queries +2. Register stubs in open source postgres service +3. Move real implementations to `commercial/services/moderation.go` +4. Implement commercial service registration with license checks + +### Phase 4: Implement Route Separation +1. Replace direct handler calls with upgrade handlers in routes.go +2. Move real handlers to `commercial/handlers/` +3. Implement dynamic route registration in commercial init +4. Test route overriding works correctly + +### Phase 5: Testing & Validation +1. Move tests to commercial folder +2. Test open source build without commercial features +3. Test commercial build with license validation +4. Verify graceful degradation and upgrade messaging +5. Test that forking open source works without commercial parts + +## Key Benefits + +### Strong Commercial Protection +- Recreating moderation requires rebuilding entire UI + API + business logic +- Substantial engineering effort (days/weeks) to replicate functionality +- Clear legal separation under different licenses + +### Clean Architecture +- Open source provides database infrastructure and settings +- Commercial enhances with actual moderation functionality +- No broken states - content flows normally when moderation disabled +- True open core model: commercial builds on open source foundation + +### Simple Build Process +- Single repository with clear folder separation +- Standard webpack build process +- License provides protection, not technical hiding +- Easy development workflow + +## Migration Considerations + +### Existing Moderation Data +- All existing moderation settings and data remain compatible +- Database schema stays in open source (infrastructure) +- Only the management interface becomes commercial + +### User Experience +- **With License**: Full moderation functionality as before +- **Without License**: Moderation simply disabled, standard user flow +- **Upgrade Path**: Clear messaging and sales funnel for commercial features + +### Development Workflow +- Developers can see all code (legal license controls usage) +- Standard build process works for both open source and commercial +- Clean separation makes it easy to add more commercial features + +## Success Metrics + +1. **Legal Protection**: Someone forking open source cannot easily recreate moderation +2. **Functional Separation**: Open source works perfectly without moderation +3. **Build Compatibility**: Both open source and commercial versions build successfully +4. **Clean Boundaries**: Clear understanding of what's core vs commercial +5. **Scalability**: Pattern works for future commercial features + +This plan provides genuine open core protection while maintaining clean architecture and manageable implementation complexity. \ No newline at end of file diff --git a/CleanShot 2025-07-01 at 20.39.05@2x.png b/CleanShot 2025-07-01 at 20.39.05@2x.png new file mode 100644 index 000000000..e917761a8 Binary files /dev/null and b/CleanShot 2025-07-01 at 20.39.05@2x.png differ diff --git a/CleanShot 2025-07-01 at 20.42.39@2x.png b/CleanShot 2025-07-01 at 20.42.39@2x.png new file mode 100644 index 000000000..e1016765e Binary files /dev/null and b/CleanShot 2025-07-01 at 20.42.39@2x.png differ diff --git a/CleanShot 2025-07-01 at 20.54.06@2x.png b/CleanShot 2025-07-01 at 20.54.06@2x.png new file mode 100644 index 000000000..da61c5a53 Binary files /dev/null and b/CleanShot 2025-07-01 at 20.54.06@2x.png differ diff --git a/MODERATION.md b/MODERATION.md new file mode 100644 index 000000000..507368fce --- /dev/null +++ b/MODERATION.md @@ -0,0 +1,57 @@ +# Moderation + +We are going to implement the ability to moderate posts and comments added to fider. The idea is that when a new post is added, that post is then flagged as unmoderated. An admin will need to approve it. + +This document explains everything that needs to change in Fider to facilitate this feature. + +## Settings + +This is a optional feature. Admins will be able to toggle this. This is done in the public/pages/Administration/pages/PrivacySettings.page.tsx page. Similar to how the other settings are controlled in this page. There needs to be a new column in the "tenants" database table called "is_moderation_enabled" to control this, so you're going to need a new migration file in migrations/ + +## New posts and comments + +Posts and comments will need a new column "is_approved" to determine if the post or comment has been approved to be shown. Again, this will need adding to the migration. + +When a new post or comment is added, if moderation is enabled then is_approved will be false, otherwise it will be true. New posts are added via public/pages/Home/components/ShareFeedback.tsx and comments via public/pages/ShowPost/components/CommentInput.tsx. + +Once added, the post is only visible to the person who added it (and admins, see below). When you view the post (or the comment) via public/pages/ShowPost/ShowPost.page.tsx, there needs to be a message to tell you that it's awaiting moderation. + +## Doing the moderation + +The "admin" section of fider looks like this (public/pages/Administration/components/AdminBasePage.tsx): +![alt text]() + +There neeeds to be a new menu item on the left for "Moderation" + +Clicking on that presents you with a tablular view of all non-moderated posts and comments. +For each row, display the following columns: +_ A checkbox to allow you to select multiple rows +_ User's name and date of post (e.g. "Matt, 10 minutes ago") +_ Wide column for the description. +_ If comment: "New comment: " (truncated to 200 chars) +_ If post: "New post: " +_ Thumbs up button to approve \* Thumbs down button to decline + +If you click the description for a post or comment, it will take you to the post, and if you clicked a comment, will highlight the comment (this is already supporeted, see how the public/pages/ShowPost/ShowPost.page.tsx page highlights comments). When you are an admin, and it's a post that's awaiting moderation, the in place of the voting button, we need 2 buttons - one to approve, one to decline. The same is true for comments, there should be an approve / decline set of buttons udner the comment. + +Declining a post or comment will delete it entirely. We should ask the user to confirm the action. + +Approving should just set it as approved, and remove it from the list (might be easier to re-fetch the content for moderation) + +Here is a screenshot showing the inspiration we found for the moderation page + +![alt text]() + +You can see the bulk actions on this screenshot, we're interested in having bulk actions to approve or decline too to make it easier, plus a "select all" to highlight them all. The UI for the bulk actions should be like the "Sort by" options on the post listing in public/pages/Home/components/PostsSort.tsx + +## Moderation Changes 1 + +We've decided to make some changes to the moderation admin: + +1. Rather than have it part of the admin menu, remove the entry from the side menu in admin. Instead, we want an icon in the top Header (public/components/Header.tsx) that when clicked, takes you to the moderation admin page, without the side menu. Ideally the icon will have a little counter in the top-right of how many items are awaiting moderation. + +2. We've decided to make the moderation less onourous to the admins by making some more changes: + + 2.1) As well as decline, you have another option - "decline and block". This option will decline the post or comment, and block the user who made it from posting again. We already have the ability to block users in Fider (see BlockUser in app/handlers/user.go), so we can hook into that. So if you had 1 user who had 5 posts and some comments, and you declined and blocked them, we would also decline all other posts and comments they made, and block them from posting again. + + 2.2) As well as "approve", you have another option - "approve and verify". This option will approve the post (or comment) and ALL other posts and comments from this user. It will also make the user "verified" for posting, meaning that any future posts or comments from that user can bypass moderation. To facilitate this, we're going to need a new column on the user, which will work similar to how blocking a user works, except that it will do the opposite. You can't be both blocked and verified, so if you "approve and verify" we'll need to unset any blocked status for that user. diff --git a/Makefile b/Makefile index 63591484f..b91f03227 100644 --- a/Makefile +++ b/Makefile @@ -34,20 +34,54 @@ build-ssr: ## Build SSR script and locales +##@ Localization + +locale-extract: ## Extract and overwrite English locale from source code + npx lingui extract --overwrite + +locale-reset: ## Reset translation for specific keys in all non-English locales (use KEY="key.name" or KEYS="key1 key2 ...") + @if [ -z "$(KEY)$(KEYS)" ]; then \ + echo "Error: KEY or KEYS variable is required."; \ + echo "Usage: make locale-reset KEY=\"some.key\""; \ + echo " or: make locale-reset KEYS=\"key1 key2 key3\""; \ + exit 1; \ + fi + @keys="$(KEY) $(KEYS)"; \ + echo "Resetting translations for: $$keys"; \ + for lang in ar cs de el es-ES fa fr it ja ko nl pl pt-BR ru si-LK sk sv-SE tr zh-CN; do \ + echo " Updating $$lang..."; \ + temp_file=$$(mktemp); \ + jq_expr=""; \ + for key in $$keys; do \ + if [ -n "$$key" ]; then \ + if [ -z "$$jq_expr" ]; then \ + jq_expr=".[\""$$key"\"] = \"\""; \ + else \ + jq_expr="$$jq_expr | .[\""$$key"\"] = \"\""; \ + fi; \ + fi; \ + done; \ + jq "$$jq_expr" locale/$$lang/client.json > $$temp_file && \ + mv $$temp_file locale/$$lang/client.json; \ + done + @echo "Done!" + + + ##@ Testing test: test-server test-ui ## Test server and ui code -test-server: build-server build-ssr ## Run all server tests +test-server: build-server build-ssr ## Run all server tests (set SHORT=false for full tests including network-dependent tests) godotenv -f .test.env ./fider migrate - godotenv -f .test.env go test ./... -race + godotenv -f .test.env go test ./... -race $(if $(filter false,$(SHORT)),,-short) test-ui: ## Run all UI tests TZ=GMT npx jest ./public -coverage-server: build-server build-ssr ## Run all server tests (with code coverage) +coverage-server: build-server build-ssr ## Run all server tests (with code coverage, set SHORT=false for full tests) godotenv -f .test.env ./fider migrate - godotenv -f .test.env go test ./... -coverprofile=cover.out -coverpkg=all -p=8 -race + godotenv -f .test.env go test ./... -coverprofile=cover.out -coverpkg=all -p=8 -race $(if $(filter false,$(SHORT)),,-short) diff --git a/app/actions/billing.go b/app/actions/billing.go deleted file mode 100644 index 0e8e9b121..000000000 --- a/app/actions/billing.go +++ /dev/null @@ -1,33 +0,0 @@ -package actions - -import ( - "context" - - "github.com/getfider/fider/app/models/entity" - "github.com/getfider/fider/app/pkg/env" - - "github.com/getfider/fider/app/pkg/validate" -) - -// GenerateCheckoutLink is used to generate a Paddle-hosted checkout link for the service subscription -type GenerateCheckoutLink struct { - PlanID string `json:"planId"` -} - -// IsAuthorized returns true if current user is authorized to perform this action -func (action *GenerateCheckoutLink) IsAuthorized(ctx context.Context, user *entity.User) bool { - return user.IsAdministrator() -} - -// Validate if current model is valid -func (action *GenerateCheckoutLink) Validate(ctx context.Context, user *entity.User) *validate.Result { - result := validate.Success() - - if !env.IsBillingEnabled() { - result.AddFieldFailure("plan_id", "Billing is not enabled.") - } else if action.PlanID != env.Config.Paddle.MonthlyPlanID && action.PlanID != env.Config.Paddle.YearlyPlanID { - result.AddFieldFailure("plan_id", "Invalid Plan ID.") - } - - return result -} diff --git a/app/actions/tenant.go b/app/actions/tenant.go index 9ecb47c03..ccbe2701b 100644 --- a/app/actions/tenant.go +++ b/app/actions/tenant.go @@ -272,8 +272,9 @@ func (action *UpdateTenantAdvancedSettings) Validate(ctx context.Context, user * // UpdateTenantPrivacySettings is the input model used to update tenant privacy settings type UpdateTenantPrivacySettings struct { - IsPrivate bool `json:"isPrivate"` - IsFeedEnabled bool `json:"isFeedEnabled"` + IsPrivate bool `json:"isPrivate"` + IsFeedEnabled bool `json:"isFeedEnabled"` + IsModerationEnabled bool `json:"isModerationEnabled"` } // IsAuthorized returns true if current user is authorized to perform this action diff --git a/app/cmd/routes.go b/app/cmd/routes.go index e315776f2..4c20a58f9 100644 --- a/app/cmd/routes.go +++ b/app/cmd/routes.go @@ -70,11 +70,10 @@ func routes(r *web.Engine) *web.Engine { r.Get("/privacy", handlers.LegalPage("Privacy Policy", "privacy.md")) - if env.IsBillingEnabled() { - wh := r.Group() - { - wh.Post("/webhooks/paddle", webhooks.IncomingPaddleWebhook()) - } + // Stripe webhooks (before CSRF middleware) + stripeWh := r.Group() + { + stripeWh.Post("/webhooks/stripe", webhooks.IncomingStripeWebhook()) } r.Use(middlewares.CSRF()) @@ -164,8 +163,9 @@ func routes(r *web.Engine) *web.Engine { ui.Get("/admin/advanced", handlers.AdvancedSettingsPage()) ui.Get("/admin/privacy", handlers.Page("Privacy · Site Settings", "", "Administration/pages/PrivacySettings.page")) ui.Get("/admin/invitations", handlers.Page("Invitations · Site Settings", "", "Administration/pages/Invitations.page")) - ui.Get("/admin/members", handlers.ManageMembers()) + ui.Get("/admin/users", handlers.ManageMembers()) ui.Get("/admin/tags", handlers.ManageTags()) + ui.Get("/admin/moderation", handlers.GetModerationPageHandler()) ui.Get("/admin/authentication", handlers.ManageAuthentication()) ui.Get("/_api/admin/oauth/:provider", handlers.GetOAuthConfig()) @@ -191,10 +191,15 @@ func routes(r *web.Engine) *web.Engine { ui.Post("/_api/admin/roles/:role/users", handlers.ChangeUserRole()) ui.Put("/_api/admin/users/:userID/block", handlers.BlockUser()) ui.Delete("/_api/admin/users/:userID/block", handlers.UnblockUser()) + ui.Put("/_api/admin/users/:userID/trust", handlers.TrustUser()) + ui.Delete("/_api/admin/users/:userID/trust", handlers.UntrustUser()) + ui.Get("/_api/admin/moderation/items", handlers.GetModerationItemsHandler()) + ui.Get("/_api/admin/moderation/count", handlers.GetModerationCountHandler()) if env.IsBillingEnabled() { ui.Get("/admin/billing", handlers.ManageBilling()) - ui.Post("/_api/billing/checkout-link", handlers.GenerateCheckoutLink()) + ui.Post("/_api/admin/billing/portal", handlers.CreateStripePortalSession()) + ui.Post("/_api/admin/billing/checkout", handlers.CreateStripeCheckoutSession()) } } @@ -265,6 +270,15 @@ func routes(r *web.Engine) *web.Engine { adminApi.Put("/api/v1/tags/:slug", apiv1.CreateEditTag()) adminApi.Delete("/api/v1/tags/:slug", apiv1.DeleteTag()) + adminApi.Post("/api/v1/admin/moderation/posts/:id/approve-and-verify", apiv1.GetApprovePostAndVerifyHandler()) + adminApi.Post("/api/v1/admin/moderation/posts/:id/decline-and-block", apiv1.GetDeclinePostAndBlockHandler()) + adminApi.Post("/api/v1/admin/moderation/posts/:id/approve", apiv1.GetApprovePostHandler()) + adminApi.Post("/api/v1/admin/moderation/posts/:id/decline", apiv1.GetDeclinePostHandler()) + adminApi.Post("/api/v1/admin/moderation/comments/:id/approve-and-verify", apiv1.GetApproveCommentAndVerifyHandler()) + adminApi.Post("/api/v1/admin/moderation/comments/:id/decline-and-block", apiv1.GetDeclineCommentAndBlockHandler()) + adminApi.Post("/api/v1/admin/moderation/comments/:id/approve", apiv1.GetApproveCommentHandler()) + adminApi.Post("/api/v1/admin/moderation/comments/:id/decline", apiv1.GetDeclineCommentHandler()) + adminApi.Use(middlewares.BlockLockedTenants()) adminApi.Delete("/api/v1/posts/:number", apiv1.DeletePost()) } diff --git a/app/cmd/server.go b/app/cmd/server.go index 540a531bf..b9c1ae12c 100644 --- a/app/cmd/server.go +++ b/app/cmd/server.go @@ -17,7 +17,6 @@ import ( "github.com/getfider/fider/app/pkg/web" "github.com/robfig/cron" - _ "github.com/getfider/fider/app/services/billing/paddle" _ "github.com/getfider/fider/app/services/blob/fs" _ "github.com/getfider/fider/app/services/blob/s3" _ "github.com/getfider/fider/app/services/blob/sql" @@ -60,10 +59,6 @@ func startJobs(ctx context.Context) { _ = c.AddJob(jobs.NewJob(ctx, "PurgeExpiredNotificationsJob", jobs.PurgeExpiredNotificationsJobHandler{})) _ = c.AddJob(jobs.NewJob(ctx, "EmailSupressionJob", jobs.EmailSupressionJobHandler{})) - if env.IsBillingEnabled() { - _ = c.AddJob(jobs.NewJob(ctx, "LockExpiredTenantsJob", jobs.LockExpiredTenantsJobHandler{})) - } - c.Start() } diff --git a/app/const.go b/app/const.go index 114118ef9..a0b230ca9 100644 --- a/app/const.go +++ b/app/const.go @@ -5,6 +5,9 @@ import "errors" // ErrNotFound represents an object not found error var ErrNotFound = errors.New("Object not found") +// ErrCommercialLicenseRequired is used when a commercial feature is accessed without a license +var ErrCommercialLicenseRequired = errors.New("Content moderation requires a commercial license") + // InvitePlaceholder represents the placeholder used by members to invite other users var InvitePlaceholder = "%invite%" diff --git a/app/handlers/admin.go b/app/handlers/admin.go index 17a4561b7..a90d75b5b 100644 --- a/app/handlers/admin.go +++ b/app/handlers/admin.go @@ -27,12 +27,19 @@ func GeneralSettingsPage() web.HandlerFunc { // AdvancedSettingsPage is the advanced settings page func AdvancedSettingsPage() web.HandlerFunc { return func(c *web.Context) error { + billingState := &query.GetStripeBillingState{} + if err := bus.Dispatch(c, billingState); err != nil { + return c.Failure(err) + } + return c.Page(http.StatusOK, web.Props{ Page: "Administration/pages/AdvancedSettings.page", Title: "Advanced · Site Settings", Data: web.Map{ - "customCSS": c.Tenant().CustomCSS, + "customCSS": c.Tenant().CustomCSS, "allowedSchemes": c.Tenant().AllowedSchemes, + "licenseKey": billingState.Result.LicenseKey, + "isCommercial": c.Tenant().IsCommercial, }, }) } @@ -84,7 +91,7 @@ func UpdateAdvancedSettings() web.HandlerFunc { } if err := bus.Dispatch(c, &cmd.UpdateTenantAdvancedSettings{ - CustomCSS: action.CustomCSS, + CustomCSS: action.CustomCSS, AllowedSchemes: action.AllowedSchemes, }); err != nil { return c.Failure(err) @@ -103,8 +110,9 @@ func UpdatePrivacySettings() web.HandlerFunc { } updateSettings := &cmd.UpdateTenantPrivacySettings{ - IsPrivate: action.IsPrivate, - IsFeedEnabled: action.IsFeedEnabled, + IsPrivate: action.IsPrivate, + IsFeedEnabled: action.IsFeedEnabled, + IsModerationEnabled: action.IsModerationEnabled, } if err := bus.Dispatch(c, updateSettings); err != nil { return c.Failure(err) @@ -136,14 +144,26 @@ func UpdateEmailAuthAllowed() web.HandlerFunc { // ManageMembers is the page used by administrators to change member's role func ManageMembers() web.HandlerFunc { return func(c *web.Context) error { - allUsers := &query.GetAllUsers{} - if err := bus.Dispatch(c, allUsers); err != nil { + // Only load first page for initial page load - subsequent pagination handled by API + page, _ := c.QueryParamAsInt("page") + if page <= 0 { + page = 1 + } + + searchUsers := &query.SearchUsers{ + Query: c.QueryParam("query"), + Roles: c.QueryParamAsArray("roles"), + Page: page, + Limit: 10, + } + + if err := bus.Dispatch(c, searchUsers); err != nil { return c.Failure(err) } - // Create an array of UserWithEmail structs from the allUsers.Result - allUsersWithEmail := make([]entity.UserWithEmail, len(allUsers.Result)) - for i, user := range allUsers.Result { + // Create an array of UserWithEmail structs from the searchUsers.Result + allUsersWithEmail := make([]entity.UserWithEmail, len(searchUsers.Result)) + for i, user := range searchUsers.Result { allUsersWithEmail[i] = entity.UserWithEmail{ User: user, } @@ -153,7 +173,8 @@ func ManageMembers() web.HandlerFunc { Page: "Administration/pages/ManageMembers.page", Title: "Manage Members · Site Settings", Data: web.Map{ - "users": allUsersWithEmail, + "users": allUsersWithEmail, + "totalPages": (searchUsers.TotalCount + 10 - 1) / 10, }, }) } diff --git a/app/handlers/admin_test.go b/app/handlers/admin_test.go index 63adfd274..2f8d864e7 100644 --- a/app/handlers/admin_test.go +++ b/app/handlers/admin_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/getfider/fider/app/models/cmd" + "github.com/getfider/fider/app/models/entity" "github.com/getfider/fider/app/models/query" . "github.com/getfider/fider/app/pkg/assert" @@ -77,9 +78,9 @@ func TestUpdateSettingsHandler_NewLogo(t *testing.T) { OnTenant(mock.DemoTenant). AsUser(mock.JonSnow). ExecutePost( - handlers.UpdateSettings(), `{ - "title": "GoT", - "invitation": "Join us!", + handlers.UpdateSettings(), `{ + "title": "GoT", + "invitation": "Join us!", "welcomeMessage": "Welcome to GoT Feedback Forum", "locale": "pt-BR", "logo": { @@ -119,10 +120,10 @@ func TestUpdateSettingsHandler_RemoveLogo(t *testing.T) { OnTenant(mock.DemoTenant). AsUser(mock.JonSnow). ExecutePost( - handlers.UpdateSettings(), `{ - "title": "GoT", - "invitation": "Join us!", - "locale": "en", + handlers.UpdateSettings(), `{ + "title": "GoT", + "invitation": "Join us!", + "locale": "en", "welcomeMessage": "Welcome to GoT Feedback Forum", "logo": { "remove": true @@ -200,7 +201,9 @@ func TestUpdatePrivacySettingsHandler(t *testing.T) { func TestManageMembersHandler(t *testing.T) { RegisterT(t) - bus.AddHandler(func(ctx context.Context, q *query.GetAllUsers) error { + bus.AddHandler(func(ctx context.Context, q *query.SearchUsers) error { + q.Result = []*entity.User{} + q.TotalCount = 0 return nil }) diff --git a/app/handlers/apiv1/moderation.go b/app/handlers/apiv1/moderation.go new file mode 100644 index 000000000..7bf70ff60 --- /dev/null +++ b/app/handlers/apiv1/moderation.go @@ -0,0 +1,61 @@ +package apiv1 + +import ( + "github.com/getfider/fider/app/pkg/web" +) + +// ApprovePost approves a post (stub - requires commercial license) +func ApprovePost() web.HandlerFunc { + return func(c *web.Context) error { + return c.BadRequest(web.Map{"error": "Content moderation requires commercial license"}) + } +} + +// DeclinePost declines (deletes) a post (stub - requires commercial license) +func DeclinePost() web.HandlerFunc { + return func(c *web.Context) error { + return c.BadRequest(web.Map{"error": "Content moderation requires commercial license"}) + } +} + +// ApproveComment approves a comment (stub - requires commercial license) +func ApproveComment() web.HandlerFunc { + return func(c *web.Context) error { + return c.BadRequest(web.Map{"error": "Content moderation requires commercial license"}) + } +} + +// DeclineComment declines (deletes) a comment (stub - requires commercial license) +func DeclineComment() web.HandlerFunc { + return func(c *web.Context) error { + return c.BadRequest(web.Map{"error": "Content moderation requires commercial license"}) + } +} + +// DeclinePostAndBlock declines (deletes) a post and blocks the user (stub - requires commercial license) +func DeclinePostAndBlock() web.HandlerFunc { + return func(c *web.Context) error { + return c.BadRequest(web.Map{"error": "Content moderation requires commercial license"}) + } +} + +// DeclineCommentAndBlock declines (deletes) a comment and blocks the user (stub - requires commercial license) +func DeclineCommentAndBlock() web.HandlerFunc { + return func(c *web.Context) error { + return c.BadRequest(web.Map{"error": "Content moderation requires commercial license"}) + } +} + +// ApprovePostAndVerify approves a post and verifies the user (stub - requires commercial license) +func ApprovePostAndVerify() web.HandlerFunc { + return func(c *web.Context) error { + return c.BadRequest(web.Map{"error": "Content moderation requires commercial license"}) + } +} + +// ApproveCommentAndVerify approves a comment and verifies the user (stub - requires commercial license) +func ApproveCommentAndVerify() web.HandlerFunc { + return func(c *web.Context) error { + return c.BadRequest(web.Map{"error": "Content moderation requires commercial license"}) + } +} diff --git a/app/handlers/apiv1/post.go b/app/handlers/apiv1/post.go index 3018e73e6..e47a22e2f 100644 --- a/app/handlers/apiv1/post.go +++ b/app/handlers/apiv1/post.go @@ -21,10 +21,11 @@ func SearchPosts() web.HandlerFunc { viewQueryParams = "all" // Set default value to "all" if not provided } searchPosts := &query.SearchPosts{ - Query: c.QueryParam("query"), - View: viewQueryParams, - Limit: c.QueryParam("limit"), - Tags: c.QueryParamAsArray("tags"), + Query: c.QueryParam("query"), + View: viewQueryParams, + Limit: c.QueryParam("limit"), + Tags: c.QueryParamAsArray("tags"), + ModerationFilter: c.QueryParam("moderation"), } if myVotesOnly, err := c.QueryParamAsBool("myvotes"); err == nil { searchPosts.MyVotesOnly = myVotesOnly diff --git a/app/handlers/apiv1/registry.go b/app/handlers/apiv1/registry.go new file mode 100644 index 000000000..43e1baf3c --- /dev/null +++ b/app/handlers/apiv1/registry.go @@ -0,0 +1,82 @@ +package apiv1 + +import ( + "github.com/getfider/fider/app/pkg/web" +) + +// Handler registry for commercial API v1 overrides +type HandlerRegistry struct { + ApprovePost func() web.HandlerFunc + DeclinePost func() web.HandlerFunc + ApproveComment func() web.HandlerFunc + DeclineComment func() web.HandlerFunc + DeclinePostAndBlock func() web.HandlerFunc + DeclineCommentAndBlock func() web.HandlerFunc + ApprovePostAndVerify func() web.HandlerFunc + ApproveCommentAndVerify func() web.HandlerFunc +} + +var registry = &HandlerRegistry{ + ApprovePost: ApprovePost, + DeclinePost: DeclinePost, + ApproveComment: ApproveComment, + DeclineComment: DeclineComment, + DeclinePostAndBlock: DeclinePostAndBlock, + DeclineCommentAndBlock: DeclineCommentAndBlock, + ApprovePostAndVerify: ApprovePostAndVerify, + ApproveCommentAndVerify: ApproveCommentAndVerify, +} + +// RegisterModerationHandlers allows commercial package to override moderation handlers +func RegisterModerationHandlers( + approvePost func() web.HandlerFunc, + declinePost func() web.HandlerFunc, + approveComment func() web.HandlerFunc, + declineComment func() web.HandlerFunc, + declinePostAndBlock func() web.HandlerFunc, + declineCommentAndBlock func() web.HandlerFunc, + approvePostAndVerify func() web.HandlerFunc, + approveCommentAndVerify func() web.HandlerFunc, +) { + registry.ApprovePost = approvePost + registry.DeclinePost = declinePost + registry.ApproveComment = approveComment + registry.DeclineComment = declineComment + registry.DeclinePostAndBlock = declinePostAndBlock + registry.DeclineCommentAndBlock = declineCommentAndBlock + registry.ApprovePostAndVerify = approvePostAndVerify + registry.ApproveCommentAndVerify = approveCommentAndVerify +} + +// Handler getters that return the registered handlers +func GetApprovePostHandler() web.HandlerFunc { + return registry.ApprovePost() +} + +func GetDeclinePostHandler() web.HandlerFunc { + return registry.DeclinePost() +} + +func GetApproveCommentHandler() web.HandlerFunc { + return registry.ApproveComment() +} + +func GetDeclineCommentHandler() web.HandlerFunc { + return registry.DeclineComment() +} + +func GetDeclinePostAndBlockHandler() web.HandlerFunc { + return registry.DeclinePostAndBlock() +} + +func GetDeclineCommentAndBlockHandler() web.HandlerFunc { + return registry.DeclineCommentAndBlock() +} + +func GetApprovePostAndVerifyHandler() web.HandlerFunc { + return registry.ApprovePostAndVerify() +} + +func GetApproveCommentAndVerifyHandler() web.HandlerFunc { + return registry.ApproveCommentAndVerify() +} \ No newline at end of file diff --git a/app/handlers/apiv1/user.go b/app/handlers/apiv1/user.go index 655d201aa..cb99dc821 100644 --- a/app/handlers/apiv1/user.go +++ b/app/handlers/apiv1/user.go @@ -12,14 +12,47 @@ import ( "github.com/getfider/fider/app/pkg/web" ) -// ListUsers returns all registered users +// ListUsers returns paginated registered users func ListUsers() web.HandlerFunc { return func(c *web.Context) error { - allUsers := &query.GetAllUsers{} - if err := bus.Dispatch(c, allUsers); err != nil { + page, _ := c.QueryParamAsInt("page") + if page <= 0 { + page = 1 + } + + limit, _ := c.QueryParamAsInt("limit") + if limit <= 0 { + limit = 10 + } + + searchUsers := &query.SearchUsers{ + Query: c.QueryParam("query"), + Roles: c.QueryParamAsArray("roles"), + Page: page, + Limit: limit, + } + + if err := bus.Dispatch(c, searchUsers); err != nil { return c.Failure(err) } - return c.Ok(allUsers.Result) + + // Create an array of UserWithEmail structs to include email in JSON response + allUsersWithEmail := make([]entity.UserWithEmail, len(searchUsers.Result)) + for i, user := range searchUsers.Result { + allUsersWithEmail[i] = entity.UserWithEmail{ + User: user, + } + } + + totalPages := (searchUsers.TotalCount + limit - 1) / limit + + return c.Ok(web.Map{ + "users": allUsersWithEmail, + "totalCount": searchUsers.TotalCount, + "totalPages": totalPages, + "page": page, + "limit": limit, + }) } } diff --git a/app/handlers/apiv1/user_test.go b/app/handlers/apiv1/user_test.go index 3a3510b22..1b6a2ec46 100644 --- a/app/handlers/apiv1/user_test.go +++ b/app/handlers/apiv1/user_test.go @@ -20,11 +20,12 @@ import ( func TestListUsersHandler(t *testing.T) { RegisterT(t) - bus.AddHandler(func(ctx context.Context, q *query.GetAllUsers) error { + bus.AddHandler(func(ctx context.Context, q *query.SearchUsers) error { q.Result = []*entity.User{ {ID: 1, Name: "User 1"}, {ID: 2, Name: "User 2"}, } + q.TotalCount = 2 return nil }) @@ -35,8 +36,9 @@ func TestListUsersHandler(t *testing.T) { ExecuteAsJSON(apiv1.ListUsers()) Expect(status).Equals(http.StatusOK) - Expect(query.IsArray()).IsTrue() - Expect(query.ArrayLength()).Equals(2) + Expect(query.IsArray()).IsFalse() // Should be an object, not an array + Expect(query.Int32("totalCount")).Equals(2) + Expect(query.Contains("users")).IsTrue() } func TestCreateUser_ExistingEmail(t *testing.T) { diff --git a/app/handlers/billing.go b/app/handlers/billing.go index 301c33843..517d8bea1 100644 --- a/app/handlers/billing.go +++ b/app/handlers/billing.go @@ -4,80 +4,104 @@ import ( "fmt" "net/http" - "github.com/getfider/fider/app/actions" - "github.com/getfider/fider/app/models/cmd" - "github.com/getfider/fider/app/models/dto" - "github.com/getfider/fider/app/models/enum" "github.com/getfider/fider/app/models/query" "github.com/getfider/fider/app/pkg/bus" "github.com/getfider/fider/app/pkg/env" "github.com/getfider/fider/app/pkg/web" + "github.com/stripe/stripe-go/v83" + portalsession "github.com/stripe/stripe-go/v83/billingportal/session" + checkoutsession "github.com/stripe/stripe-go/v83/checkout/session" ) -// ManageBilling is the page used by administrators for billing settings +// ManageBilling is the page used by administrators for Stripe billing settings func ManageBilling() web.HandlerFunc { return func(c *web.Context) error { - - // It's not possible to use custom domains on billing page, so redirect to Fider url - if c.Request.IsCustomDomain() { - url := fmt.Sprintf("https://%s.%s/admin/billing", c.Tenant().Subdomain, env.Config.HostDomain) - return c.Redirect(url) - } - - billingState := &query.GetBillingState{} + billingState := &query.GetStripeBillingState{} if err := bus.Dispatch(c, billingState); err != nil { return c.Failure(err) } - billingSubscription := &query.GetBillingSubscription{ - SubscriptionID: billingState.Result.SubscriptionID, - } - if billingState.Result.Status == enum.BillingActive { - if err := bus.Dispatch(c, billingSubscription); err != nil { - return c.Failure(err) - } - } - return c.Page(http.StatusOK, web.Props{ Page: "Administration/pages/ManageBilling.page", Title: "Manage Billing · Site Settings", Data: web.Map{ - "paddle": web.Map{ - "isSandbox": env.Config.Paddle.IsSandbox, - "vendorId": env.Config.Paddle.VendorID, - "monthlyPlanId": env.Config.Paddle.MonthlyPlanID, - "yearlyPlanId": env.Config.Paddle.YearlyPlanID, - }, - "status": billingState.Result.Status, - "trialEndsAt": billingState.Result.TrialEndsAt, - "subscriptionEndsAt": billingState.Result.SubscriptionEndsAt, - "subscription": billingSubscription.Result, + "stripeCustomerID": billingState.Result.CustomerID, + "stripeSubscriptionID": billingState.Result.SubscriptionID, + "licenseKey": billingState.Result.LicenseKey, + "paddleSubscriptionID": billingState.Result.PaddleSubscriptionID, + "isCommercial": c.Tenant().IsCommercial, }, }) } } -// GenerateCheckoutLink generates a Paddle-hosted checkout link for the service subscription -func GenerateCheckoutLink() web.HandlerFunc { +// CreateStripePortalSession creates a Stripe customer portal session +func CreateStripePortalSession() web.HandlerFunc { return func(c *web.Context) error { - action := new(actions.GenerateCheckoutLink) - if result := c.BindTo(action); !result.Ok { - return c.HandleValidation(result) + billingState := &query.GetStripeBillingState{} + if err := bus.Dispatch(c, billingState); err != nil { + return c.Failure(err) + } + + if billingState.Result.CustomerID == "" { + return c.BadRequest(web.Map{"message": "No Stripe customer found"}) + } + + stripe.Key = env.Config.Stripe.SecretKey + + returnURL := c.BaseURL() + "/admin/billing" + + params := &stripe.BillingPortalSessionParams{ + Customer: stripe.String(billingState.Result.CustomerID), + ReturnURL: stripe.String(returnURL), } - generateLink := &cmd.GenerateCheckoutLink{ - PlanID: action.PlanID, - Passthrough: dto.PaddlePassthrough{ - TenantID: c.Tenant().ID, + s, err := portalsession.New(params) + if err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{ + "url": s.URL, + }) + } +} + +// CreateStripeCheckoutSession creates a Stripe checkout session for new subscriptions +func CreateStripeCheckoutSession() web.HandlerFunc { + return func(c *web.Context) error { + stripe.Key = env.Config.Stripe.SecretKey + + returnURL := c.BaseURL() + "/admin/billing" + tenantID := c.Tenant().ID + + params := &stripe.CheckoutSessionParams{ + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + LineItems: []*stripe.CheckoutSessionLineItemParams{ + { + Price: stripe.String(env.Config.Stripe.PriceID), + Quantity: stripe.Int64(1), + }, + }, + SuccessURL: stripe.String(returnURL + "?checkout=success"), + CancelURL: stripe.String(returnURL + "?checkout=cancelled"), + Metadata: map[string]string{ + "tenant_id": fmt.Sprintf("%d", tenantID), + }, + SubscriptionData: &stripe.CheckoutSessionSubscriptionDataParams{ + Metadata: map[string]string{ + "tenant_id": fmt.Sprintf("%d", tenantID), + }, }, } - if err := bus.Dispatch(c, generateLink); err != nil { + s, err := checkoutsession.New(params) + if err != nil { return c.Failure(err) } return c.Ok(web.Map{ - "url": generateLink.URL, + "url": s.URL, }) } } diff --git a/app/handlers/billing_test.go b/app/handlers/billing_test.go deleted file mode 100644 index 34236ab22..000000000 --- a/app/handlers/billing_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package handlers_test - -import ( - "context" - "net/http" - "testing" - "time" - - "github.com/getfider/fider/app/models/cmd" - "github.com/getfider/fider/app/models/dto" - "github.com/getfider/fider/app/models/entity" - "github.com/getfider/fider/app/models/enum" - - "github.com/getfider/fider/app/models/query" - . "github.com/getfider/fider/app/pkg/assert" - "github.com/getfider/fider/app/pkg/bus" - "github.com/getfider/fider/app/pkg/env" - "github.com/getfider/fider/app/pkg/mock" - - "github.com/getfider/fider/app/handlers" -) - -func TestManageBillingHandler_RedirectWhenUsingCNAME(t *testing.T) { - RegisterT(t) - - server := mock.NewServer() - code, response := server. - OnTenant(mock.DemoTenant). - AsUser(mock.JonSnow). - WithURL("https://feedback.demo.com/admin/billing"). - Execute(handlers.ManageBilling()) - - Expect(code).Equals(http.StatusTemporaryRedirect) - Expect(response.Header().Get("Location")).Equals("https://demo.test.fider.io/admin/billing") -} - -func TestManageBillingHandler_ReturnsCorrectBillingInformation(t *testing.T) { - RegisterT(t) - - bus.AddHandler(func(ctx context.Context, q *query.GetBillingState) error { - trialEndsAt := time.Date(2021, time.February, 2, 4, 2, 2, 0, time.UTC) - q.Result = &entity.BillingState{ - Status: enum.BillingActive, - PlanID: "PLAN-123", - SubscriptionID: "SUB-123", - TrialEndsAt: &trialEndsAt, - } - return nil - }) - - bus.AddHandler(func(ctx context.Context, q *query.GetBillingSubscription) error { - Expect(q.SubscriptionID).Equals("SUB-123") - - q.Result = &entity.BillingSubscription{ - UpdateURL: "https://sandbox-subscription-management.paddle.com/subscription/SUB-123/hash/1111/update", - CancelURL: "https://sandbox-subscription-management.paddle.com/subscription/SUB-123/hash/1111/cancel", - PaymentInformation: entity.BillingPaymentInformation{ - PaymentMethod: "card", - CardType: "visa", - LastFourDigits: "1111", - ExpiryDate: "10/2031", - }, - LastPayment: entity.BillingPayment{ - Amount: float64(30), - Currency: "USD", - Date: "2021-11-09", - }, - } - return nil - }) - - server := mock.NewServer() - code, page := server. - OnTenant(mock.DemoTenant). - AsUser(mock.JonSnow). - WithURL("https://demo.test.fider.io/admin/billing"). - ExecuteAsPage(handlers.ManageBilling()) - - Expect(code).Equals(http.StatusOK) - Expect(page.Data).ContainsProps(dto.Props{ - "status": float64(2), - "trialEndsAt": "2021-02-02T04:02:02Z", - "subscriptionEndsAt": nil, - }) - - Expect(page.Data["subscription"]).ContainsProps(dto.Props{ - "updateURL": "https://sandbox-subscription-management.paddle.com/subscription/SUB-123/hash/1111/update", - "cancelURL": "https://sandbox-subscription-management.paddle.com/subscription/SUB-123/hash/1111/cancel", - }) - - ExpectHandler(&query.GetBillingState{}).CalledOnce() - ExpectHandler(&query.GetBillingSubscription{}).CalledOnce() -} - -func TestGenerateCheckoutLinkHandler(t *testing.T) { - RegisterT(t) - - env.Config.Paddle.VendorID = "123" - env.Config.Paddle.VendorAuthCode = "456" - env.Config.Paddle.MonthlyPlanID = "PLAN_M" - env.Config.Paddle.YearlyPlanID = "PLAN_Y" - - bus.AddHandler(func(ctx context.Context, c *cmd.GenerateCheckoutLink) error { - c.URL = "https://paddle.com/fake-checkout-url" - return nil - }) - - server := mock.NewServer() - code, json := server. - WithURL("http://demo.test.fider.io/_api/billing/checkout-link"). - OnTenant(mock.DemoTenant). - AsUser(mock.JonSnow). - ExecutePostAsJSON(handlers.GenerateCheckoutLink(), "{ \"planID\": \"PLAN_M\" }") - - Expect(code).Equals(http.StatusOK) - Expect(json.String("url")).Equals("https://paddle.com/fake-checkout-url") - ExpectHandler(&cmd.GenerateCheckoutLink{}).CalledOnce() -} diff --git a/app/handlers/moderation.go b/app/handlers/moderation.go new file mode 100644 index 000000000..2dd2ea7b3 --- /dev/null +++ b/app/handlers/moderation.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "github.com/getfider/fider/app" + "github.com/getfider/fider/app/pkg/web" +) + +// ModerationPage is the moderation administration page (stub - requires commercial license) +func ModerationPage() web.HandlerFunc { + return func(c *web.Context) error { + return c.Failure(app.ErrCommercialLicenseRequired) + } +} + +// GetModerationItems returns all unmoderated posts and comments (stub - requires commercial license) +func GetModerationItems() web.HandlerFunc { + return func(c *web.Context) error { + return c.Failure(app.ErrCommercialLicenseRequired) + } +} + +// GetModerationCount returns the count of items awaiting moderation (stub - requires commercial license) +func GetModerationCount() web.HandlerFunc { + return func(c *web.Context) error { + // Return 0 instead of error to allow UI to function normally + return c.Ok(web.Map{ + "count": 0, + }) + } +} diff --git a/app/handlers/post.go b/app/handlers/post.go index ced787e70..776d4c03b 100644 --- a/app/handlers/post.go +++ b/app/handlers/post.go @@ -36,7 +36,24 @@ func Index() web.HandlerFunc { searchPosts.MyPostsOnly = myPostsOnly } - searchPosts.SetStatusesFromStrings(c.QueryParamAsArray("statuses")) + // Handle "pending" pseudo-status for moderation filtering + statusesParam := c.QueryParamAsArray("statuses") + hasPending := false + actualStatuses := []string{} + for _, status := range statusesParam { + if status == "pending" { + hasPending = true + } else { + actualStatuses = append(actualStatuses, status) + } + } + + // Set moderation filter based on pending status + if hasPending { + searchPosts.ModerationFilter = "pending" + } + + searchPosts.SetStatusesFromStrings(actualStatuses) getAllTags := &query.GetAllTags{} countPerStatus := &query.CountPostPerStatus{} diff --git a/app/handlers/registry.go b/app/handlers/registry.go new file mode 100644 index 000000000..138708967 --- /dev/null +++ b/app/handlers/registry.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "github.com/getfider/fider/app/pkg/web" +) + +// Handler registry for commercial overrides +type HandlerRegistry struct { + ModerationPage func() web.HandlerFunc + GetModerationItems func() web.HandlerFunc + GetModerationCount func() web.HandlerFunc +} + +var registry = &HandlerRegistry{ + ModerationPage: ModerationPage, + GetModerationItems: GetModerationItems, + GetModerationCount: GetModerationCount, +} + +// RegisterModerationHandlers allows commercial package to override moderation handlers +func RegisterModerationHandlers( + moderationPage func() web.HandlerFunc, + getModerationItems func() web.HandlerFunc, + getModerationCount func() web.HandlerFunc, +) { + registry.ModerationPage = moderationPage + registry.GetModerationItems = getModerationItems + registry.GetModerationCount = getModerationCount +} + +// Handler getters that return the registered handlers +func GetModerationPageHandler() web.HandlerFunc { + return registry.ModerationPage() +} + +func GetModerationItemsHandler() web.HandlerFunc { + return registry.GetModerationItems() +} + +func GetModerationCountHandler() web.HandlerFunc { + return registry.GetModerationCount() +} \ No newline at end of file diff --git a/app/handlers/user.go b/app/handlers/user.go index d4fd646b6..dedd1064a 100644 --- a/app/handlers/user.go +++ b/app/handlers/user.go @@ -39,3 +39,37 @@ func UnblockUser() web.HandlerFunc { return c.Ok(web.Map{}) } } + +// TrustUser is used to trust an existing user +func TrustUser() web.HandlerFunc { + return func(c *web.Context) error { + userID, err := c.ParamAsInt("userID") + if err != nil { + return c.NotFound() + } + + err = bus.Dispatch(c, &cmd.TrustUser{UserID: userID}) + if err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{}) + } +} + +// UntrustUser is used to untrust an existing user +func UntrustUser() web.HandlerFunc { + return func(c *web.Context) error { + userID, err := c.ParamAsInt("userID") + if err != nil { + return c.NotFound() + } + + err = bus.Dispatch(c, &cmd.UntrustUser{UserID: userID}) + if err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{}) + } +} diff --git a/app/handlers/webhooks/paddle.go b/app/handlers/webhooks/paddle.go deleted file mode 100644 index 9469b6e56..000000000 --- a/app/handlers/webhooks/paddle.go +++ /dev/null @@ -1,163 +0,0 @@ -package webhooks - -import ( - "crypto" - "crypto/rsa" - "crypto/sha1" - "crypto/x509" - "encoding/base64" - "encoding/json" - "encoding/pem" - "fmt" - "net/url" - "sort" - "time" - - "github.com/getfider/fider/app/models/cmd" - "github.com/getfider/fider/app/models/dto" - "github.com/getfider/fider/app/models/enum" - "github.com/getfider/fider/app/pkg/bus" - "github.com/getfider/fider/app/pkg/env" - "github.com/getfider/fider/app/pkg/errors" - "github.com/getfider/fider/app/pkg/log" - "github.com/getfider/fider/app/pkg/web" - "github.com/getfider/fider/app/tasks" -) - -// IncomingPaddleWebhook handles all incoming requests from Paddle Webhooks -func IncomingPaddleWebhook() web.HandlerFunc { - return func(c *web.Context) error { - params, err := url.ParseQuery(c.Request.Body) - if err != nil { - return c.Failure(err) - } - - err = verifyPaddleSig(params, env.Config.Paddle.PublicKey) - if err != nil { - return c.Failure(errors.Wrap(err, "failed to verity paddle signature")) - } - - action := params.Get("alert_name") - switch action { - case "subscription_created": - return handlePaddleSubscriptionCreated(c, params) - case "subscription_cancelled": - return handlePaddleSubscriptionCancelled(c, params) - default: - log.Warnf(c, "Unsupported Paddle webhook action: '@{Action}'", dto.Props{ - "Action": action, - }) - return c.Ok(web.Map{}) - } - } -} - -func handlePaddleSubscriptionCreated(c *web.Context, params url.Values) error { - passthrough := dto.PaddlePassthrough{} - if err := json.Unmarshal([]byte(params.Get("passthrough")), &passthrough); err != nil { - return c.Failure(err) - } - - activate := &cmd.ActivateBillingSubscription{ - TenantID: passthrough.TenantID, - SubscriptionID: params.Get("subscription_id"), - PlanID: params.Get("subscription_plan_id"), - } - - if err := bus.Dispatch(c, activate); err != nil { - return c.Failure(err) - } - - // Handle userlist. - if env.Config.UserList.Enabled { - c.Enqueue(tasks.UserListUpdateCompany(&dto.UserListUpdateCompany{ - TenantID: passthrough.TenantID, - BillingStatus: enum.BillingActive, - })) - } - - return c.Ok(web.Map{}) -} - -func handlePaddleSubscriptionCancelled(c *web.Context, params url.Values) error { - passthrough := dto.PaddlePassthrough{} - if err := json.Unmarshal([]byte(params.Get("passthrough")), &passthrough); err != nil { - return c.Failure(err) - } - - cancellationEffectiveDate := params.Get("cancellation_effective_date") - subscriptionEndsAt, err := time.Parse("2006-01-02", cancellationEffectiveDate) - if err != nil { - log.Error(c, errors.Wrap(err, "failed to parse date '%s'", cancellationEffectiveDate)) - subscriptionEndsAt = time.Now().AddDate(0, 0, 30) - } - - cancel := &cmd.CancelBillingSubscription{ - TenantID: passthrough.TenantID, - SubscriptionEndsAt: subscriptionEndsAt, - } - - if err := bus.Dispatch(c, cancel); err != nil { - return c.Failure(err) - } - - // Handle userlist. - if env.Config.UserList.Enabled { - c.Enqueue(tasks.UserListUpdateCompany(&dto.UserListUpdateCompany{ - TenantID: passthrough.TenantID, - BillingStatus: enum.BillingCancelled, - })) - } - - return c.Ok(web.Map{}) -} - -// verifyPaddleSig verifies the p_signature parameter sent -// in Paddle webhooks. 'values' is the decoded form values sent -// in the webhook response body. -func verifyPaddleSig(values url.Values, publicKeyPEM string) error { - der, _ := pem.Decode([]byte(publicKeyPEM)) - if der == nil { - return errors.New("Could not parse public key pem") - } - - pub, err := x509.ParsePKIXPublicKey(der.Bytes) - if err != nil { - return errors.New("Could not parse public key pem der") - } - - signingKey, ok := pub.(*rsa.PublicKey) - if !ok { - return errors.New("Not the correct key format") - } - - sig, err := base64.StdEncoding.DecodeString(values.Get("p_signature")) - if err != nil { - return err - } - - values.Del("p_signature") - sha1Sum := sha1.Sum(phpserialize(values)) - err = rsa.VerifyPKCS1v15(signingKey, crypto.SHA1, sha1Sum[:], sig) - if err != nil { - return err - } - - return nil -} - -func phpserialize(form url.Values) []byte { - var keys []string - for k := range form { - keys = append(keys, k) - } - sort.Strings(keys) - - serialized := fmt.Sprintf("a:%d:{", len(keys)) - for _, k := range keys { - serialized += fmt.Sprintf("s:%d:\"%s\";s:%d:\"%s\";", len(k), k, len(form.Get(k)), form.Get(k)) - } - serialized += "}" - - return []byte(serialized) -} diff --git a/app/handlers/webhooks/stripe.go b/app/handlers/webhooks/stripe.go new file mode 100644 index 000000000..0d5a0c536 --- /dev/null +++ b/app/handlers/webhooks/stripe.go @@ -0,0 +1,141 @@ +package webhooks + +import ( + "encoding/json" + "strconv" + + "github.com/getfider/fider/app/models/cmd" + "github.com/getfider/fider/app/models/dto" + "github.com/getfider/fider/app/models/enum" + "github.com/getfider/fider/app/models/query" + "github.com/getfider/fider/app/pkg/bus" + "github.com/getfider/fider/app/pkg/env" + "github.com/getfider/fider/app/pkg/errors" + "github.com/getfider/fider/app/pkg/log" + "github.com/getfider/fider/app/pkg/web" + "github.com/getfider/fider/app/services/license" + "github.com/getfider/fider/app/tasks" + "github.com/stripe/stripe-go/v83" + "github.com/stripe/stripe-go/v83/webhook" +) + +// IncomingStripeWebhook handles all incoming requests from Stripe Webhooks +func IncomingStripeWebhook() web.HandlerFunc { + return func(c *web.Context) error { + payload := []byte(c.Request.Body) + sigHeader := c.Request.GetHeader("Stripe-Signature") + event, err := webhook.ConstructEvent(payload, sigHeader, env.Config.Stripe.WebhookSecret) + if err != nil { + return c.Failure(errors.Wrap(err, "failed to verify stripe webhook signature")) + } + + switch event.Type { + case "checkout.session.completed": + return handleCheckoutSessionCompleted(c, event) + case "customer.subscription.deleted": + return handleSubscriptionDeleted(c, event) + default: + log.Debugf(c, "Ignoring Stripe webhook event: '@{EventType}'", dto.Props{ + "EventType": event.Type, + }) + return c.Ok(web.Map{}) + } + } +} + +func handleCheckoutSessionCompleted(c *web.Context, event stripe.Event) error { + var session stripe.CheckoutSession + if err := json.Unmarshal(event.Data.Raw, &session); err != nil { + return c.Failure(errors.Wrap(err, "failed to parse checkout session")) + } + + // Get tenant ID from metadata + tenantIDStr, ok := session.Metadata["tenant_id"] + if !ok { + return c.Failure(errors.New("tenant_id not found in session metadata")) + } + + tenantID, err := strconv.Atoi(tenantIDStr) + if err != nil { + return c.Failure(errors.Wrap(err, "failed to parse tenant_id")) + } + + // Generate license key for this tenant + licenseKey := license.GenerateKey(tenantID) + + activate := &cmd.ActivateStripeSubscription{ + TenantID: tenantID, + CustomerID: session.Customer.ID, + SubscriptionID: session.Subscription.ID, + LicenseKey: licenseKey, + } + + if err := bus.Dispatch(c, activate); err != nil { + return c.Failure(err) + } + + log.Infof(c, "Stripe subscription activated for tenant @{TenantID}", dto.Props{ + "TenantID": tenantID, + }) + + // Update UserList with new plan + if env.Config.UserList.Enabled { + getTenant := &query.GetTenantByDomain{Domain: strconv.Itoa(tenantID)} + if err := bus.Dispatch(c, getTenant); err == nil && getTenant.Result != nil { + c.Enqueue(tasks.UserListUpdateCompany(&dto.UserListUpdateCompany{ + TenantID: tenantID, + Name: getTenant.Result.Name, + Plan: enum.PlanPro, + })) + } + } + + return c.Ok(web.Map{}) +} + +func handleSubscriptionDeleted(c *web.Context, event stripe.Event) error { + var subscription stripe.Subscription + if err := json.Unmarshal(event.Data.Raw, &subscription); err != nil { + return c.Failure(errors.Wrap(err, "failed to parse subscription")) + } + + // Get tenant ID from metadata + tenantIDStr, ok := subscription.Metadata["tenant_id"] + if !ok { + log.Warnf(c, "tenant_id not found in subscription metadata for subscription @{SubscriptionID}", dto.Props{ + "SubscriptionID": subscription.ID, + }) + return c.Ok(web.Map{}) + } + + tenantID, err := strconv.Atoi(tenantIDStr) + if err != nil { + return c.Failure(errors.Wrap(err, "failed to parse tenant_id")) + } + + cancel := &cmd.CancelStripeSubscription{ + TenantID: tenantID, + } + + if err := bus.Dispatch(c, cancel); err != nil { + return c.Failure(err) + } + + log.Infof(c, "Stripe subscription cancelled for tenant @{TenantID}", dto.Props{ + "TenantID": tenantID, + }) + + // Update UserList with new plan + if env.Config.UserList.Enabled { + getTenant := &query.GetTenantByDomain{Domain: strconv.Itoa(tenantID)} + if err := bus.Dispatch(c, getTenant); err == nil && getTenant.Result != nil { + c.Enqueue(tasks.UserListUpdateCompany(&dto.UserListUpdateCompany{ + TenantID: tenantID, + Name: getTenant.Result.Name, + Plan: enum.PlanFree, + })) + } + } + + return c.Ok(web.Map{}) +} diff --git a/app/jobs/lock_expired_tenants_job.go b/app/jobs/lock_expired_tenants_job.go deleted file mode 100644 index 3128ff0a1..000000000 --- a/app/jobs/lock_expired_tenants_job.go +++ /dev/null @@ -1,42 +0,0 @@ -package jobs - -import ( - "github.com/getfider/fider/app/models/cmd" - "github.com/getfider/fider/app/models/dto" - "github.com/getfider/fider/app/models/enum" - "github.com/getfider/fider/app/pkg/bus" - "github.com/getfider/fider/app/pkg/env" - "github.com/getfider/fider/app/pkg/log" -) - -type LockExpiredTenantsJobHandler struct { -} - -func (e LockExpiredTenantsJobHandler) Schedule() string { - return "0 0 0 * * *" // every day at 0:00 -} - -func (e LockExpiredTenantsJobHandler) Run(ctx Context) error { - c := &cmd.LockExpiredTenants{} - err := bus.Dispatch(ctx, c) - if err != nil { - return err - } - - log.Debugf(ctx, "@{Count} tenants marked as locked", dto.Props{ - "Count": c.NumOfTenantsLocked, - }) - - // Handle userlist - if env.Config.UserList.Enabled && c.NumOfTenantsLocked > 0 { - for _, tenant := range c.TenantsLocked { - err := bus.Dispatch(ctx, &cmd.UserListUpdateCompany{TenantId: tenant, BillingStatus: enum.BillingCancelled}) - if err != nil { - return err - } - } - - } - - return nil -} diff --git a/app/jobs/lock_expired_tenants_job_test.go b/app/jobs/lock_expired_tenants_job_test.go deleted file mode 100644 index 35c86cc14..000000000 --- a/app/jobs/lock_expired_tenants_job_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package jobs_test - -import ( - "context" - "testing" - - "github.com/getfider/fider/app/jobs" - "github.com/getfider/fider/app/models/cmd" - . "github.com/getfider/fider/app/pkg/assert" - "github.com/getfider/fider/app/pkg/bus" -) - -func TestLockExpiredTenantsJob_Schedule_IsCorrect(t *testing.T) { - RegisterT(t) - - job := &jobs.LockExpiredTenantsJobHandler{} - Expect(job.Schedule()).Equals("0 0 0 * * *") -} - -func TestLockExpiredTenantsJob_ShouldJustDispatchCommand(t *testing.T) { - RegisterT(t) - - bus.AddHandler(func(ctx context.Context, c *cmd.LockExpiredTenants) error { - return nil - }) - - job := &jobs.LockExpiredTenantsJobHandler{} - err := job.Run(jobs.Context{ - Context: context.Background(), - }) - Expect(err).IsNil() -} diff --git a/app/middlewares/security.go b/app/middlewares/security.go index c48216809..0f407ec74 100644 --- a/app/middlewares/security.go +++ b/app/middlewares/security.go @@ -15,8 +15,11 @@ func Secure() web.MiddlewareFunc { return func(next web.HandlerFunc) web.HandlerFunc { return func(c *web.Context) error { cdnHost := env.Config.CDN.Host - if cdnHost != "" && !env.IsSingleHostMode() { - cdnHost = "*." + cdnHost + if cdnHost != "" { + if !env.IsSingleHostMode() { + cdnHost = "*." + cdnHost + } + cdnHost = " " + cdnHost } csp := fmt.Sprintf(web.CspPolicyTemplate, c.ContextID(), cdnHost) diff --git a/app/middlewares/security_test.go b/app/middlewares/security_test.go index cec889bcc..d0e73dafe 100644 --- a/app/middlewares/security_test.go +++ b/app/middlewares/security_test.go @@ -23,7 +23,7 @@ func TestSecureWithoutCDN(t *testing.T) { return c.NoContent(http.StatusOK) }) - expectedPolicy := "base-uri 'self'; default-src 'self'; style-src 'self' 'unsafe-inline' https://*.paddle.com ; script-src 'self' 'nonce-" + ctxID + "' https://www.google-analytics.com https://*.paddle.com ; img-src 'self' https: data: ; font-src 'self' data: ; object-src 'none'; media-src 'none'; connect-src 'self' https://www.google-analytics.com ; frame-src 'self' https://*.paddle.com" + expectedPolicy := "base-uri 'self'; default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'nonce-" + ctxID + "' https://www.google-analytics.com; img-src 'self' https: data:; font-src 'self' data:; object-src 'none'; media-src 'none'; connect-src 'self' https://www.google-analytics.com; frame-src 'self'" Expect(status).Equals(http.StatusOK) Expect(response.Header().Get("Content-Security-Policy")).Equals(expectedPolicy) @@ -46,7 +46,7 @@ func TestSecureWithCDN(t *testing.T) { return c.NoContent(http.StatusOK) }) - expectedPolicy := "base-uri 'self'; default-src 'self'; style-src 'self' 'unsafe-inline' https://*.paddle.com *.test.fider.io; script-src 'self' 'nonce-" + ctxID + "' https://www.google-analytics.com https://*.paddle.com *.test.fider.io; img-src 'self' https: data: *.test.fider.io; font-src 'self' data: *.test.fider.io; object-src 'none'; media-src 'none'; connect-src 'self' https://www.google-analytics.com *.test.fider.io; frame-src 'self' https://*.paddle.com" + expectedPolicy := "base-uri 'self'; default-src 'self'; style-src 'self' 'unsafe-inline' *.test.fider.io; script-src 'self' 'nonce-" + ctxID + "' https://www.google-analytics.com *.test.fider.io; img-src 'self' https: data: *.test.fider.io; font-src 'self' data: *.test.fider.io; object-src 'none'; media-src 'none'; connect-src 'self' https://www.google-analytics.com *.test.fider.io; frame-src 'self'" Expect(status).Equals(http.StatusOK) Expect(response.Header().Get("Content-Security-Policy")).Equals(expectedPolicy) @@ -69,7 +69,7 @@ func TestSecureWithCDN_SingleHost(t *testing.T) { return c.NoContent(http.StatusOK) }) - expectedPolicy := "base-uri 'self'; default-src 'self'; style-src 'self' 'unsafe-inline' https://*.paddle.com test.fider.io; script-src 'self' 'nonce-" + ctxID + "' https://www.google-analytics.com https://*.paddle.com test.fider.io; img-src 'self' https: data: test.fider.io; font-src 'self' data: test.fider.io; object-src 'none'; media-src 'none'; connect-src 'self' https://www.google-analytics.com test.fider.io; frame-src 'self' https://*.paddle.com" + expectedPolicy := "base-uri 'self'; default-src 'self'; style-src 'self' 'unsafe-inline' test.fider.io; script-src 'self' 'nonce-" + ctxID + "' https://www.google-analytics.com test.fider.io; img-src 'self' https: data: test.fider.io; font-src 'self' data: test.fider.io; object-src 'none'; media-src 'none'; connect-src 'self' https://www.google-analytics.com test.fider.io; frame-src 'self'" Expect(status).Equals(http.StatusOK) Expect(response.Header().Get("Content-Security-Policy")).Equals(expectedPolicy) diff --git a/app/models/cmd/billing.go b/app/models/cmd/billing.go index d211fb3d8..feb46c819 100644 --- a/app/models/cmd/billing.go +++ b/app/models/cmd/billing.go @@ -1,32 +1,20 @@ package cmd -import ( - "time" - - "github.com/getfider/fider/app/models/dto" -) - -type GenerateCheckoutLink struct { - PlanID string - Passthrough dto.PaddlePassthrough +type ActivateBillingSubscription struct { + TenantID int +} - // Output - URL string +type CancelBillingSubscription struct { + TenantID int } -type ActivateBillingSubscription struct { +type ActivateStripeSubscription struct { TenantID int + CustomerID string SubscriptionID string - PlanID string -} - -type CancelBillingSubscription struct { - TenantID int - SubscriptionEndsAt time.Time + LicenseKey string } -type LockExpiredTenants struct { - //Output - NumOfTenantsLocked int64 - TenantsLocked []int +type CancelStripeSubscription struct { + TenantID int } diff --git a/app/models/cmd/moderation.go b/app/models/cmd/moderation.go new file mode 100644 index 000000000..4ca5b4083 --- /dev/null +++ b/app/models/cmd/moderation.go @@ -0,0 +1,31 @@ +package cmd + +type ApprovePost struct { + PostID int +} + +type DeclinePost struct { + PostID int +} + +type ApproveComment struct { + CommentID int +} + +type DeclineComment struct { + CommentID int +} + +type BulkApproveItems struct { + PostIDs []int + CommentIDs []int +} + +type BulkDeclineItems struct { + PostIDs []int + CommentIDs []int +} + +type TrustUser struct { + UserID int +} diff --git a/app/models/cmd/tenant.go b/app/models/cmd/tenant.go index 62b0006fc..b8515468c 100644 --- a/app/models/cmd/tenant.go +++ b/app/models/cmd/tenant.go @@ -17,8 +17,9 @@ type CreateTenant struct { } type UpdateTenantPrivacySettings struct { - IsPrivate bool - IsFeedEnabled bool + IsPrivate bool + IsFeedEnabled bool + IsModerationEnabled bool } type UpdateTenantEmailAuthAllowedSettings struct { diff --git a/app/models/cmd/user.go b/app/models/cmd/user.go index 136c16e4e..81e25f1dd 100644 --- a/app/models/cmd/user.go +++ b/app/models/cmd/user.go @@ -14,6 +14,10 @@ type UnblockUser struct { UserID int } +type UntrustUser struct { + UserID int +} + type RegenerateAPIKey struct { Result string } diff --git a/app/models/cmd/userlist.go b/app/models/cmd/userlist.go index 2da89f581..29887000c 100644 --- a/app/models/cmd/userlist.go +++ b/app/models/cmd/userlist.go @@ -3,20 +3,20 @@ package cmd import "github.com/getfider/fider/app/models/enum" type UserListCreateCompany struct { - Name string - TenantId int - SignedUpAt string - BillingStatus string - Subdomain string - UserId int - UserEmail string - UserName string + Name string + TenantId int + SignedUpAt string + Plan enum.Plan + Subdomain string + UserId int + UserEmail string + UserName string } type UserListUpdateCompany struct { - TenantId int - Name string - BillingStatus enum.BillingStatus + TenantId int + Name string + Plan enum.Plan } type UserListUpdateUser struct { diff --git a/app/models/dto/billing.go b/app/models/dto/billing.go deleted file mode 100644 index b644483b4..000000000 --- a/app/models/dto/billing.go +++ /dev/null @@ -1,5 +0,0 @@ -package dto - -type PaddlePassthrough struct { - TenantID int `json:"tenant_id"` -} diff --git a/app/models/dto/userlist.go b/app/models/dto/userlist.go index 8679684d9..2eb183f14 100644 --- a/app/models/dto/userlist.go +++ b/app/models/dto/userlist.go @@ -3,7 +3,7 @@ package dto import "github.com/getfider/fider/app/models/enum" type UserListUpdateCompany struct { - TenantID int - Name string - BillingStatus enum.BillingStatus + TenantID int + Name string + Plan enum.Plan } diff --git a/app/models/entity/billing.go b/app/models/entity/billing.go index f862cf5cb..3ac91ef0a 100644 --- a/app/models/entity/billing.go +++ b/app/models/entity/billing.go @@ -1,19 +1,5 @@ package entity -import ( - "time" - - "github.com/getfider/fider/app/models/enum" -) - -type BillingState struct { - Status enum.BillingStatus `json:"status"` - PlanID string `json:"planID"` - SubscriptionID string `json:"subscriptionID"` - TrialEndsAt *time.Time `json:"trialEndsAt"` - SubscriptionEndsAt *time.Time `json:"subscriptionEndsAt"` -} - type BillingSubscription struct { UpdateURL string `json:"updateURL"` CancelURL string `json:"cancelURL"` @@ -34,3 +20,10 @@ type BillingPayment struct { Currency string `json:"currency"` Date string `json:"date"` } + +type StripeBillingState struct { + CustomerID string `json:"customerID"` + SubscriptionID string `json:"subscriptionID"` + LicenseKey string `json:"licenseKey"` + PaddleSubscriptionID string `json:"paddleSubscriptionID"` +} diff --git a/app/models/entity/comment.go b/app/models/entity/comment.go index b07135415..c1343cc5a 100644 --- a/app/models/entity/comment.go +++ b/app/models/entity/comment.go @@ -20,4 +20,5 @@ type Comment struct { EditedAt *time.Time `json:"editedAt,omitempty"` EditedBy *User `json:"editedBy,omitempty"` ReactionCounts []ReactionCounts `json:"reactionCounts,omitempty"` + IsApproved bool `json:"isApproved"` } diff --git a/app/models/entity/post.go b/app/models/entity/post.go index 98273b017..3e2a93348 100644 --- a/app/models/entity/post.go +++ b/app/models/entity/post.go @@ -22,6 +22,7 @@ type Post struct { Status enum.PostStatus `json:"status"` Response *PostResponse `json:"response,omitempty"` Tags []string `json:"tags"` + IsApproved bool `json:"isApproved"` } // CanBeVoted returns true if this post can have its vote changed diff --git a/app/models/entity/tenant.go b/app/models/entity/tenant.go index 98c92e2a6..7787a0b5f 100644 --- a/app/models/entity/tenant.go +++ b/app/models/entity/tenant.go @@ -1,24 +1,28 @@ package entity -import "github.com/getfider/fider/app/models/enum" +import ( + "github.com/getfider/fider/app/models/enum" +) // Tenant represents a tenant type Tenant struct { - ID int `json:"id"` - Name string `json:"name"` - Subdomain string `json:"subdomain"` - Invitation string `json:"invitation"` - WelcomeMessage string `json:"welcomeMessage"` - CNAME string `json:"cname"` - Status enum.TenantStatus `json:"status"` - Locale string `json:"locale"` - IsPrivate bool `json:"isPrivate"` - LogoBlobKey string `json:"logoBlobKey"` - CustomCSS string `json:"-"` - AllowedSchemes string `json:"allowedSchemes"` - IsEmailAuthAllowed bool `json:"isEmailAuthAllowed"` - IsFeedEnabled bool `json:"isFeedEnabled"` - PreventIndexing bool `json:"preventIndexing"` + ID int `json:"id"` + Name string `json:"name"` + Subdomain string `json:"subdomain"` + Invitation string `json:"invitation"` + WelcomeMessage string `json:"welcomeMessage"` + CNAME string `json:"cname"` + Status enum.TenantStatus `json:"status"` + Locale string `json:"locale"` + IsPrivate bool `json:"isPrivate"` + LogoBlobKey string `json:"logoBlobKey"` + CustomCSS string `json:"-"` + AllowedSchemes string `json:"allowedSchemes"` + IsEmailAuthAllowed bool `json:"isEmailAuthAllowed"` + IsFeedEnabled bool `json:"isFeedEnabled"` + PreventIndexing bool `json:"preventIndexing"` + IsModerationEnabled bool `json:"isModerationEnabled"` + IsCommercial bool `json:"isCommercial"` } func (t *Tenant) IsDisabled() bool { diff --git a/app/models/entity/user.go b/app/models/entity/user.go index 8e270e293..0a70c2a38 100644 --- a/app/models/entity/user.go +++ b/app/models/entity/user.go @@ -18,6 +18,7 @@ type User struct { AvatarType enum.AvatarType `json:"-"` AvatarURL string `json:"avatarURL,omitempty"` Status enum.UserStatus `json:"status"` + IsTrusted bool `json:"isTrusted"` } // HasProvider returns true if current user has registered with given provider @@ -40,6 +41,11 @@ func (u *User) IsAdministrator() bool { return u.Role == enum.RoleAdministrator } +// RequiresModeration returns true if user requires moderation +func (u *User) RequiresModeration() bool { + return u.Role == enum.RoleVisitor && !u.IsTrusted +} + // UserProvider represents the relationship between an User and an Authentication provide type UserProvider struct { Name string diff --git a/app/models/entity/user_test.go b/app/models/entity/user_test.go index af391886b..bd7d4a58a 100644 --- a/app/models/entity/user_test.go +++ b/app/models/entity/user_test.go @@ -21,7 +21,7 @@ func TestUserWithEmail_MarshalJSON(t *testing.T) { }, } - expectedJSON := `{"id":1,"name":"John Doe","role":"visitor","status":"active","email":"johndoe@example.com"}` + expectedJSON := `{"id":1,"name":"John Doe","role":"visitor","status":"active","isTrusted":false,"email":"johndoe@example.com"}` jsonData, err := json.Marshal(user) if err != nil { @@ -43,7 +43,7 @@ func TestUser_MarshalJSON(t *testing.T) { Status: 1, } - expectedJSON := `{"id":1,"name":"John Doe","role":"visitor","status":"active"}` + expectedJSON := `{"id":1,"name":"John Doe","role":"visitor","status":"active","isTrusted":false}` jsonData, err := json.Marshal(user) if err != nil { diff --git a/app/models/enum/billing.go b/app/models/enum/billing.go deleted file mode 100644 index b80178e79..000000000 --- a/app/models/enum/billing.go +++ /dev/null @@ -1,29 +0,0 @@ -package enum - -type BillingStatus int - -var ( - //BillingTrial is used for tenants in trial - BillingTrial BillingStatus = 1 - //BillingActive is used for tenants with an active subscription - BillingActive BillingStatus = 2 - //BillingCancelled is used for tenants that had an active subscription, but have cancelled it - BillingCancelled BillingStatus = 3 - //BillingFreeForever is used for tenants that are on a forever free plan - BillingFreeForever BillingStatus = 4 - //BillingOpenCollective is used for tenants that have an active open collective subsription - BillingOpenCollective BillingStatus = 5 -) - -var billingStatusIDs = map[BillingStatus]string{ - BillingTrial: "trial", - BillingActive: "active", - BillingCancelled: "cancelled", - BillingFreeForever: "free_forever", - BillingOpenCollective: "open_collective", -} - -// String returns the string version of the billing status -func (status BillingStatus) String() string { - return billingStatusIDs[status] -} diff --git a/app/models/enum/plan.go b/app/models/enum/plan.go new file mode 100644 index 000000000..606d8a222 --- /dev/null +++ b/app/models/enum/plan.go @@ -0,0 +1,20 @@ +package enum + +type Plan int + +var ( + //PlanFree is used for tenants on the free plan + PlanFree Plan = 1 + //PlanPro is used for tenants on the pro plan + PlanPro Plan = 2 +) + +var planIDs = map[Plan]string{ + PlanFree: "free", + PlanPro: "pro", +} + +// String returns the string version of the plan +func (p Plan) String() string { + return planIDs[p] +} diff --git a/app/models/query/billing.go b/app/models/query/billing.go index 7114c85a1..1a5a5c42b 100644 --- a/app/models/query/billing.go +++ b/app/models/query/billing.go @@ -4,14 +4,14 @@ import ( "github.com/getfider/fider/app/models/entity" ) -type GetBillingState struct { - // Output - Result *entity.BillingState -} - type GetBillingSubscription struct { SubscriptionID string // Output Result *entity.BillingSubscription } + +type GetStripeBillingState struct { + // Output + Result *entity.StripeBillingState +} diff --git a/app/models/query/moderation.go b/app/models/query/moderation.go new file mode 100644 index 000000000..5e3a90001 --- /dev/null +++ b/app/models/query/moderation.go @@ -0,0 +1,28 @@ +package query + +import ( + "time" + + "github.com/getfider/fider/app/models/entity" +) + +type ModerationItem struct { + Type string `json:"type"` // "post" or "comment" + ID int `json:"id"` + PostID int `json:"postId,omitempty"` + PostNumber int `json:"postNumber,omitempty"` + PostSlug string `json:"postSlug,omitempty"` + Title string `json:"title,omitempty"` + Content string `json:"content"` + User *entity.UserWithEmail `json:"user"` + CreatedAt time.Time `json:"createdAt"` + PostTitle string `json:"postTitle,omitempty"` +} + +type GetModerationItems struct { + Result []*ModerationItem +} + +type GetModerationCount struct { + Result int +} diff --git a/app/models/query/post.go b/app/models/query/post.go index 5e9dde5fb..e87621cf9 100644 --- a/app/models/query/post.go +++ b/app/models/query/post.go @@ -34,14 +34,15 @@ type GetPostByNumber struct { } type SearchPosts struct { - Query string - View string - Limit string - Statuses []enum.PostStatus - Tags []string - MyVotesOnly bool - NoTagsOnly bool - MyPostsOnly bool + Query string + View string + Limit string + Statuses []enum.PostStatus + Tags []string + MyVotesOnly bool + NoTagsOnly bool + MyPostsOnly bool + ModerationFilter string // "pending", "approved", or empty (all) Result []*entity.Post } diff --git a/app/models/query/tenant.go b/app/models/query/tenant.go index fddc71c7a..6cdba0fd3 100644 --- a/app/models/query/tenant.go +++ b/app/models/query/tenant.go @@ -1,8 +1,6 @@ package query import ( - "time" - "github.com/getfider/fider/app/models/entity" "github.com/getfider/fider/app/models/enum" ) @@ -51,13 +49,6 @@ type GetTenantByDomain struct { Result *entity.Tenant } -type GetTrialingTenantContacts struct { - TrialExpiresOn time.Time - - // Output - Contacts []*entity.User -} - type GetPendingSignUpVerification struct { // Output Result *entity.EmailVerification diff --git a/app/models/query/user.go b/app/models/query/user.go index dc2c7f1e0..ddcd3dce2 100644 --- a/app/models/query/user.go +++ b/app/models/query/user.go @@ -51,3 +51,13 @@ type GetAllUsers struct { type GetAllUsersNames struct { Result []*dto.UserNames } + +type SearchUsers struct { + Query string + Roles []string + Page int + Limit int + + Result []*entity.User + TotalCount int +} diff --git a/app/pkg/dbx/mapping.go b/app/pkg/dbx/mapping.go index be484bd8b..1b484b66b 100644 --- a/app/pkg/dbx/mapping.go +++ b/app/pkg/dbx/mapping.go @@ -1,6 +1,7 @@ package dbx import ( + "database/sql" "fmt" "reflect" "sync" @@ -63,7 +64,11 @@ func (m *RowMapper) Map(dest any, columns []string, scanner func(dest ...any) er panic(fmt.Sprintf("Field not found for column %s", c)) } - if field.Kind() == reflect.Slice && field.Type().Elem().Kind() != reflect.Uint8 { + // Check if the field type implements sql.Scanner + scannerType := reflect.TypeOf((*sql.Scanner)(nil)).Elem() + implementsScanner := field.Addr().Type().Implements(scannerType) + + if field.Kind() == reflect.Slice && field.Type().Elem().Kind() != reflect.Uint8 && !implementsScanner { obj := reflect.New(reflect.MakeSlice(field.Type(), 0, 0).Type()).Elem() field.Set(obj) pointers[i] = pq.Array(field.Addr().Interface()) diff --git a/app/pkg/dbx/setup.sql b/app/pkg/dbx/setup.sql index 592cfa75c..da434a116 100644 --- a/app/pkg/dbx/setup.sql +++ b/app/pkg/dbx/setup.sql @@ -2,47 +2,44 @@ TRUNCATE TABLE blobs RESTART IDENTITY CASCADE; TRUNCATE TABLE logs RESTART IDENTITY CASCADE; TRUNCATE TABLE tenants RESTART IDENTITY CASCADE; -INSERT INTO tenants (name, subdomain, created_at, cname, invitation, welcome_message, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, prevent_indexing) -VALUES ('Demonstration', 'demo', now(), '', '', '', 1, false, '', '', 'en', true, true, false); +INSERT INTO tenants (name, subdomain, created_at, cname, invitation, welcome_message, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, prevent_indexing, is_moderation_enabled, is_pro) +VALUES ('Demonstration', 'demo', now(), '', '', '', 1, false, '', '', 'en', true, true, false, false, false); -INSERT INTO users (name, email, tenant_id, created_at, role, status, avatar_type, avatar_bkey) +INSERT INTO users (name, email, tenant_id, created_at, role, status, avatar_type, avatar_bkey) VALUES ('Jon Snow', 'jon.snow@got.com', 1, now(), 3, 1, 2, ''); -INSERT INTO user_providers (user_id, tenant_id, provider, provider_uid, created_at) +INSERT INTO user_providers (user_id, tenant_id, provider, provider_uid, created_at) VALUES (1, 1, 'facebook', 'FB1234', now()); -INSERT INTO users (name, email, tenant_id, created_at, role, status, avatar_type, avatar_bkey) +INSERT INTO users (name, email, tenant_id, created_at, role, status, avatar_type, avatar_bkey) VALUES ('Arya Stark', 'arya.stark@got.com', 1, now(), 1, 1, 2, ''); -INSERT INTO user_providers (user_id, tenant_id, provider, provider_uid, created_at) +INSERT INTO user_providers (user_id, tenant_id, provider, provider_uid, created_at) VALUES (2, 1, 'google', 'GO5678', now()); -INSERT INTO users (name, email, tenant_id, created_at, role, status, avatar_type, avatar_bkey) +INSERT INTO users (name, email, tenant_id, created_at, role, status, avatar_type, avatar_bkey) VALUES ('Sansa Stark', 'sansa.stark@got.com', 1, now(), 1, 1, 2, ''); -INSERT INTO tenants (name, subdomain, created_at, cname, invitation, welcome_message, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, prevent_indexing) -VALUES ('Avengers', 'avengers', now(), 'feedback.avengers.com', '', '', 1, false, '', '', 'en', true, true, false); +INSERT INTO tenants (name, subdomain, created_at, cname, invitation, welcome_message, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, prevent_indexing, is_moderation_enabled, is_pro) +VALUES ('Avengers', 'avengers', now(), 'feedback.avengers.com', '', '', 1, false, '', '', 'en', true, true, false, false, false); -INSERT INTO users (name, email, tenant_id, created_at, role, status, avatar_type, avatar_bkey) +INSERT INTO users (name, email, tenant_id, created_at, role, status, avatar_type, avatar_bkey) VALUES ('Tony Stark', 'tony.stark@avengers.com', 2, now(), 3, 1, 2, ''); -INSERT INTO user_providers (user_id, tenant_id, provider, provider_uid, created_at) +INSERT INTO user_providers (user_id, tenant_id, provider, provider_uid, created_at) VALUES (4, 2, 'facebook', 'FB2222', now()); -INSERT INTO users (name, email, tenant_id, created_at, role, status, avatar_type, avatar_bkey) +INSERT INTO users (name, email, tenant_id, created_at, role, status, avatar_type, avatar_bkey) VALUES ('The Hulk', 'the.hulk@avengers.com', 2, now(), 1, 1, 2, ''); -INSERT INTO user_providers (user_id, tenant_id, provider, provider_uid, created_at) +INSERT INTO user_providers (user_id, tenant_id, provider, provider_uid, created_at) VALUES (5, 2, 'google', 'GO1111', now()); --- Create a tenant that has reached the end of it's trial period -INSERT INTO tenants (name, subdomain, created_at, cname, invitation, welcome_message, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, prevent_indexing) -VALUES ('Trial Expired', 'trial-expired', now(), 'feedback.trial-expired.com', '', '', 1, false, '', '', 'en', true, true, false); -INSERT INTO tenants_billing (tenant_id, paddle_plan_id, paddle_subscription_id, status, subscription_ends_at, trial_ends_at) -VALUES (3, 1, 1,1, now(), CURRENT_DATE - INTERVAL '10 days'); +INSERT INTO tenants (name, subdomain, created_at, cname, invitation, welcome_message, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, prevent_indexing, is_moderation_enabled, is_pro) +VALUES ('Orange Inc', 'orange', now(), 'feedback.orange.com', '', '', 1, false, '', '', 'en', true, true, false, false, false); INSERT INTO users (name, email, tenant_id, created_at, role, status, avatar_type, avatar_bkey) -VALUES ('Trial Expired', 'trial.expired@trial-expired.com', 3, now(), 3, 1, 2, ''); +VALUES ('Orange Admin', 'admin@orange.com', 3, now(), 3, 1, 2, ''); INSERT INTO user_providers (user_id, tenant_id, provider, provider_uid, created_at) VALUES (6, 3, 'facebook', 'FB3333', now()); -INSERT INTO tenants (name, subdomain, created_at, cname, invitation, welcome_message, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, prevent_indexing) -VALUES ('Demonstration German', 'german', now(), '', '', '', 1, false, '', '', 'de', true, true, false); +INSERT INTO tenants (name, subdomain, created_at, cname, invitation, welcome_message, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, prevent_indexing, is_moderation_enabled, is_pro) +VALUES ('Demonstration German', 'german', now(), '', '', '', 1, false, '', '', 'de', true, true, false, false, false); INSERT INTO users (name, email, tenant_id, created_at, role, status, avatar_type, avatar_bkey) VALUES ('Jon Snow', 'jon.snow@german.com', 4, now(), 3, 1, 2, ''); diff --git a/app/pkg/env/env.go b/app/pkg/env/env.go index 462810621..406892180 100644 --- a/app/pkg/env/env.go +++ b/app/pkg/env/env.go @@ -56,13 +56,10 @@ type config struct { JWTSecret string `env:"JWT_SECRET,required"` PostCreationWithTagsEnabled bool `env:"POST_CREATION_WITH_TAGS_ENABLED,default=false"` AllowAllowedSchemes bool `env:"ALLOW_ALLOWED_SCHEMES,default=true"` - Paddle struct { - IsSandbox bool `env:"PADDLE_SANDBOX,default=false"` - VendorID string `env:"PADDLE_VENDOR_ID"` - VendorAuthCode string `env:"PADDLE_VENDOR_AUTHCODE"` - MonthlyPlanID string `env:"PADDLE_MONTHLY_PLAN_ID"` - YearlyPlanID string `env:"PADDLE_YEARLY_PLAN_ID"` - PublicKey string `env:"PADDLE_PUBLIC_KEY"` + Stripe struct { + SecretKey string `env:"STRIPE_SECRET_KEY"` + WebhookSecret string `env:"STRIPE_WEBHOOK_SECRET"` + PriceID string `env:"STRIPE_PRICE_ID"` } Metrics struct { Enabled bool `env:"METRICS_ENABLED,default=false"` @@ -146,6 +143,10 @@ type config struct { } GoogleAnalytics string `env:"GOOGLE_ANALYTICS"` SearchNoiseWords string `env:"SEARCH_NOISE_WORDS,default=add|support|for|implement|create|make|allow|enable|provide|some|also|include|very|make|and|for|to|a|able|function|feature|app"` + License struct { + MasterSecret string `env:"LICENSE_MASTER_SECRET"` + Key string `env:"COMMERCIAL_KEY"` + } } // Config is a strongly typed reference to all configuration parsed from Environment Variables @@ -241,9 +242,14 @@ func MultiTenantDomain() string { return "" } -// IsBillingEnabled returns true if Paddle is configured +// IsBillingEnabled returns true if Stripe is configured func IsBillingEnabled() bool { - return Config.Paddle.VendorID != "" && Config.Paddle.VendorAuthCode != "" + return Config.Stripe.SecretKey != "" +} + +// IsMultiHostMode returns true if host mode is set to multi tenant +func IsMultiHostMode() bool { + return Config.HostMode == "multi" } // IsProduction returns true on Fider production environment diff --git a/app/pkg/web/context.go b/app/pkg/web/context.go index a4fc17c08..95064535c 100644 --- a/app/pkg/web/context.go +++ b/app/pkg/web/context.go @@ -273,6 +273,10 @@ func (c *Context) Failure(err error) error { return c.NotFound() } + if cause == app.ErrCommercialLicenseRequired { + return c.Forbidden() + } + if renderErr := c.Page(http.StatusInternalServerError, Props{ Page: "Error/Error500.page", Title: "Shoot! Well, this is unexpected…", diff --git a/app/pkg/web/engine.go b/app/pkg/web/engine.go index 17b368229..204a13791 100644 --- a/app/pkg/web/engine.go +++ b/app/pkg/web/engine.go @@ -27,14 +27,14 @@ import ( var ( cspBase = "base-uri 'self'" cspDefault = "default-src 'self'" - cspStyle = "style-src 'self' 'unsafe-inline' https://*.paddle.com %[2]s" - cspScript = "script-src 'self' 'nonce-%[1]s' https://www.google-analytics.com https://*.paddle.com %[2]s" - cspFont = "font-src 'self' data: %[2]s" - cspImage = "img-src 'self' https: data: %[2]s" + cspStyle = "style-src 'self' 'unsafe-inline'%[2]s" + cspScript = "script-src 'self' 'nonce-%[1]s' https://www.google-analytics.com%[2]s" + cspFont = "font-src 'self' data:%[2]s" + cspImage = "img-src 'self' https: data:%[2]s" cspObject = "object-src 'none'" - cspFrame = "frame-src 'self' https://*.paddle.com" + cspFrame = "frame-src 'self'" cspMedia = "media-src 'none'" - cspConnect = "connect-src 'self' https://www.google-analytics.com %[2]s" + cspConnect = "connect-src 'self' https://www.google-analytics.com%[2]s" //CspPolicyTemplate is the template used to generate the policy CspPolicyTemplate = fmt.Sprintf("%s; %s; %s; %s; %s; %s; %s; %s; %s; %s", cspBase, cspDefault, cspStyle, cspScript, cspImage, cspFont, cspObject, cspMedia, cspConnect, cspFrame) diff --git a/app/pkg/web/renderer.go b/app/pkg/web/renderer.go index c4c8bba8f..4fa325bad 100644 --- a/app/pkg/web/renderer.go +++ b/app/pkg/web/renderer.go @@ -232,6 +232,7 @@ func (r *Renderer) Render(w io.Writer, statusCode int, props Props, ctx *Context "avatarBlobKey": u.AvatarBlobKey, "isAdministrator": u.IsAdministrator(), "isCollaborator": u.IsCollaborator(), + "isTrusted": u.IsTrusted, } } diff --git a/app/pkg/web/ssl_test.go b/app/pkg/web/ssl_test.go index e16fe1f8f..a4328a18b 100644 --- a/app/pkg/web/ssl_test.go +++ b/app/pkg/web/ssl_test.go @@ -39,6 +39,9 @@ func mockGetTenantWithIncorrectSubdomains(ctx context.Context, q *query.GetTenan } func TestUseAutoCert_WhenCNAMEAreRegistered(t *testing.T) { + if testing.Short() { + t.Skip("skipping test that requires real DNS lookups and ACME requests") + } RegisterT(t) bus.Init(fs.Service{}) bus.AddHandler(mockGetTenantWithCorrectSubdomains) @@ -112,6 +115,9 @@ func TestGetCertificate_WhenCNAMEAreNotConfigured(t *testing.T) { } func TestGetCertificate_WhenCNAMEDoesntMatch(t *testing.T) { + if testing.Short() { + t.Skip("skipping test that requires real DNS lookups") + } RegisterT(t) bus.Init(fs.Service{}) diff --git a/app/pkg/web/testdata/home_ssr.html b/app/pkg/web/testdata/home_ssr.html index 32c2823bf..04444844f 100755 --- a/app/pkg/web/testdata/home_ssr.html +++ b/app/pkg/web/testdata/home_ssr.html @@ -45,7 +45,7 @@

Please enable JavaScript

diff --git a/app/pkg/web/testdata/tenant.html b/app/pkg/web/testdata/tenant.html index 3792b4999..627dd2ee6 100755 --- a/app/pkg/web/testdata/tenant.html +++ b/app/pkg/web/testdata/tenant.html @@ -45,7 +45,7 @@

Please enable JavaScript

diff --git a/app/pkg/web/testdata/user.html b/app/pkg/web/testdata/user.html index 2a7455e5d..168530a36 100755 --- a/app/pkg/web/testdata/user.html +++ b/app/pkg/web/testdata/user.html @@ -45,7 +45,7 @@

Please enable JavaScript

diff --git a/app/services/billing/paddle/paddle.go b/app/services/billing/paddle/paddle.go deleted file mode 100644 index b0d4ea597..000000000 --- a/app/services/billing/paddle/paddle.go +++ /dev/null @@ -1,153 +0,0 @@ -package paddle - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "strings" - - "github.com/getfider/fider/app/models/cmd" - "github.com/getfider/fider/app/models/entity" - "github.com/getfider/fider/app/models/query" - "github.com/getfider/fider/app/pkg/bus" - "github.com/getfider/fider/app/pkg/env" - "github.com/getfider/fider/app/pkg/errors" - "github.com/getfider/fider/app/pkg/jsonq" -) - -func init() { - bus.Register(Service{}) -} - -type Service struct{} - -func (s Service) Name() string { - return "Paddle" -} - -func (s Service) Category() string { - return "billing" -} - -func (s Service) Enabled() bool { - return env.IsBillingEnabled() -} - -func (s Service) Init() { - bus.AddHandler(generateCheckoutLink) - bus.AddHandler(getBillingSubscription) -} - -func getApiBasePath() string { - if env.Config.Paddle.IsSandbox { - return "https://sandbox-vendors.paddle.com" - } - return "https://vendors.paddle.com" -} - -// generateCheckoutLink generates a checkout link using Paddle API -func generateCheckoutLink(ctx context.Context, c *cmd.GenerateCheckoutLink) error { - passthrough, err := json.Marshal(c.Passthrough) - if err != nil { - return errors.Wrap(err, "failed to marshal Passthrough object") - } - - params := url.Values{} - params.Set("vendor_id", env.Config.Paddle.VendorID) - params.Set("vendor_auth_code", env.Config.Paddle.VendorAuthCode) - params.Set("product_id", c.PlanID) - params.Set("passthrough", string(passthrough)) - - req := &cmd.HTTPRequest{ - URL: fmt.Sprintf("%s/api/2.0/product/generate_pay_link", getApiBasePath()), - Body: strings.NewReader(params.Encode()), - Method: http.MethodPost, - Headers: map[string]string{ - "Content-Type": "application/x-www-form-urlencoded", - }, - } - - if err := bus.Dispatch(ctx, req); err != nil { - return errors.Wrap(err, "failed to generate paddle checkout link") - } - - if req.ResponseStatusCode >= 300 { - return errors.New("unexpected status code while generating a paddle checkout link: %d", req.ResponseStatusCode) - } - - res := &PaddleResponse{} - if err := json.Unmarshal(req.ResponseBody, &res); err != nil { - return errors.Wrap(err, "failed to unmarshal response body") - } - - if !res.IsSuccess { - return errors.New("failed to generate paddle checkout link with '%s (%d)'", res.Error.Message, res.Error.Code) - } - - c.URL = jsonq.New(string(res.Response)).String("url") - return nil -} - -func getBillingSubscription(ctx context.Context, q *query.GetBillingSubscription) error { - params := url.Values{} - params.Set("vendor_id", env.Config.Paddle.VendorID) - params.Set("vendor_auth_code", env.Config.Paddle.VendorAuthCode) - params.Set("subscription_id", q.SubscriptionID) - - req := &cmd.HTTPRequest{ - URL: fmt.Sprintf("%s/api/2.0/subscription/users", getApiBasePath()), - Body: strings.NewReader(params.Encode()), - Method: http.MethodPost, - Headers: map[string]string{ - "Content-Type": "application/x-www-form-urlencoded", - }, - } - - if err := bus.Dispatch(ctx, req); err != nil { - return errors.Wrap(err, "failed to get paddle subscription details") - } - - if req.ResponseStatusCode >= 300 { - return errors.New("unexpected status code while fetching paddle subscription details: %d", req.ResponseStatusCode) - } - - res := &PaddleResponse{} - if err := json.Unmarshal(req.ResponseBody, &res); err != nil { - return errors.Wrap(err, "failed to unmarshal response body") - } - - if !res.IsSuccess { - return errors.New("failed to fetch paddle subscription details with '%s (%d)'", res.Error.Message, res.Error.Code) - } - - sub := []PaddleSubscriptionItem{} - if err := json.Unmarshal(res.Response, &sub); err != nil { - return errors.Wrap(err, "failed to unmarshal response body") - } - - if len(sub) > 0 { - q.Result = &entity.BillingSubscription{ - CancelURL: sub[0].CancelURL, - UpdateURL: sub[0].UpdateURL, - PaymentInformation: entity.BillingPaymentInformation{ - PaymentMethod: sub[0].PaymentInformation.PaymentMethod, - CardType: sub[0].PaymentInformation.CardType, - LastFourDigits: sub[0].PaymentInformation.LastFourDigits, - ExpiryDate: sub[0].PaymentInformation.ExpiryDate, - }, - LastPayment: entity.BillingPayment{ - Amount: sub[0].LastPayment.Amount, - Currency: sub[0].LastPayment.Currency, - Date: sub[0].LastPayment.Date, - }, - NextPayment: entity.BillingPayment{ - Amount: sub[0].NextPayment.Amount, - Currency: sub[0].NextPayment.Currency, - Date: sub[0].NextPayment.Date, - }, - } - } - return nil -} diff --git a/app/services/billing/paddle/response.go b/app/services/billing/paddle/response.go deleted file mode 100644 index 74685844a..000000000 --- a/app/services/billing/paddle/response.go +++ /dev/null @@ -1,34 +0,0 @@ -package paddle - -import "encoding/json" - -type PaddleResponse struct { - IsSuccess bool `json:"success"` - Response json.RawMessage `json:"response"` - Error struct { - Code int `json:"code"` - Message string `json:"message"` - } -} - -type PaddleSubscriptionItem struct { - SignupDate string `json:"signup_date"` - UpdateURL string `json:"update_url"` - CancelURL string `json:"cancel_url"` - PaymentInformation struct { - PaymentMethod string `json:"payment_method"` - CardType string `json:"card_type"` - LastFourDigits string `json:"last_four_digits"` - ExpiryDate string `json:"expiry_date"` - } `json:"payment_information"` - LastPayment struct { - Amount float64 `json:"amount"` - Currency string `json:"currency"` - Date string `json:"date"` - } `json:"last_payment"` - NextPayment struct { - Amount float64 `json:"amount"` - Currency string `json:"currency"` - Date string `json:"date"` - } `json:"next_payment"` -} diff --git a/app/services/license.go b/app/services/license.go new file mode 100644 index 000000000..3e49f8f3f --- /dev/null +++ b/app/services/license.go @@ -0,0 +1,46 @@ +package services + +// LicenseService provides license validation for commercial features +type LicenseService interface { + // IsCommercialFeatureEnabled checks if a specific commercial feature is licensed + IsCommercialFeatureEnabled(feature string) bool + + // GetLicenseInfo returns information about the current license + GetLicenseInfo() *LicenseInfo +} + +// LicenseInfo contains information about the current license +type LicenseInfo struct { + IsValid bool `json:"isValid"` + Features []string `json:"features"` + ExpiresAt *string `json:"expiresAt,omitempty"` + LicenseHolder string `json:"licenseHolder"` +} + +// Commercial feature constants +const ( + FeatureContentModeration = "content-moderation" +) + +// Default implementation that denies all commercial features +type defaultLicenseService struct{} + +func (s *defaultLicenseService) IsCommercialFeatureEnabled(feature string) bool { + return false +} + +func (s *defaultLicenseService) GetLicenseInfo() *LicenseInfo { + return &LicenseInfo{ + IsValid: false, + Features: []string{}, + LicenseHolder: "Open Source", + } +} + +// Global license service instance - can be overridden by commercial code +var License LicenseService = &defaultLicenseService{} + +// Helper function for easy access +func IsCommercialFeatureEnabled(feature string) bool { + return License.IsCommercialFeatureEnabled(feature) +} \ No newline at end of file diff --git a/app/services/license/validator.go b/app/services/license/validator.go new file mode 100644 index 000000000..78ed3c4ea --- /dev/null +++ b/app/services/license/validator.go @@ -0,0 +1,107 @@ +package license + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" + + "github.com/getfider/fider/app/pkg/env" + "github.com/getfider/fider/app/pkg/errors" +) + +// GenerateKey generates a commercial license key for a tenant +// Format: FIDER-COMMERCIAL-{tenantID}-{timestamp}-{hmacHex} +// Panics if LICENSE_MASTER_SECRET is not set +func GenerateKey(tenantID int) string { + if env.Config.License.MasterSecret == "" { + panic("LICENSE_MASTER_SECRET environment variable must be set to generate license keys. This is required for hosted Fider instances that sell Pro subscriptions.") + } + timestamp := time.Now().Unix() + data := fmt.Sprintf("%d-%d", tenantID, timestamp) + mac := hmac.New(sha256.New, []byte(env.Config.License.MasterSecret)) + mac.Write([]byte(data)) + hash := hex.EncodeToString(mac.Sum(nil)) + return fmt.Sprintf("FIDER-COMMERCIAL-%d-%d-%s", tenantID, timestamp, hash) +} + +// ValidationResult contains the result of license key validation +type ValidationResult struct { + IsValid bool + TenantID int + Error error +} + +// ValidateKey validates a commercial license key and returns the tenant ID +// Requires LICENSE_MASTER_SECRET to be set for validation +func ValidateKey(key string) *ValidationResult { + if key == "" { + return &ValidationResult{ + IsValid: false, + TenantID: 0, + Error: errors.New("license key is empty"), + } + } + + if env.Config.License.MasterSecret == "" { + return &ValidationResult{ + IsValid: false, + TenantID: 0, + Error: errors.New("LICENSE_MASTER_SECRET environment variable must be set to validate license keys"), + } + } + + parts := strings.Split(key, "-") + if len(parts) != 5 || parts[0] != "FIDER" || parts[1] != "COMMERCIAL" { + return &ValidationResult{ + IsValid: false, + TenantID: 0, + Error: errors.New("invalid key format: expected FIDER-COMMERCIAL-{tenantID}-{timestamp}-{hmac}"), + } + } + + tenantID, err := strconv.Atoi(parts[2]) + if err != nil { + return &ValidationResult{ + IsValid: false, + TenantID: 0, + Error: errors.Wrap(err, "invalid tenant ID in license key"), + } + } + + timestamp, err := strconv.ParseInt(parts[3], 10, 64) + if err != nil { + return &ValidationResult{ + IsValid: false, + TenantID: 0, + Error: errors.Wrap(err, "invalid timestamp in license key"), + } + } + + providedHash := parts[4] + + // Recompute HMAC to verify signature + data := fmt.Sprintf("%d-%d", tenantID, timestamp) + mac := hmac.New(sha256.New, []byte(env.Config.License.MasterSecret)) + mac.Write([]byte(data)) + expectedHash := hex.EncodeToString(mac.Sum(nil)) + + // Constant-time comparison to prevent timing attacks + if subtle.ConstantTimeCompare([]byte(expectedHash), []byte(providedHash)) != 1 { + return &ValidationResult{ + IsValid: false, + TenantID: 0, + Error: errors.New("invalid signature: license key verification failed"), + } + } + + return &ValidationResult{ + IsValid: true, + TenantID: tenantID, + Error: nil, + } +} diff --git a/app/services/sqlstore/dbEntities/README.md b/app/services/sqlstore/dbEntities/README.md new file mode 100644 index 000000000..6d9106320 --- /dev/null +++ b/app/services/sqlstore/dbEntities/README.md @@ -0,0 +1,60 @@ +# dbEntities Package + +This package contains database entity mapping structs that convert PostgreSQL query results to domain models. + +## Purpose + +The `dbEntities` package provides a centralized location for database-to-model conversion logic, separating data mapping concerns from the business logic in the `postgres` package. + +## Exported Types + +### User (and UserProvider) + +The `User` type is **exported** for use in commercial/premium features: + +```go +import "github.com/getfider/fider/app/services/sqlstore/dbEntities" + +// Use dbEntities.User in commercial code +var user dbEntities.User +err := trx.Get(&user, "SELECT id, name, email... FROM users WHERE id = $1", userID) +if err != nil { + return err +} + +// Convert to entity.User +entityUser := user.ToModel(ctx) +``` + +## Unexported Types + +All other types (comment, post, tag, tenant, etc.) are **unexported** (lowercase) and only used internally by the `postgres` package. They can be exported later if needed by simply capitalizing the type name and its `toModel` method. + +## Architecture + +**Current State:** +- `postgres` package: Contains all the db* struct definitions and uses them directly +- `dbEntities` package: Contains the same struct definitions with User exported +- Both coexist without conflicts + +**Usage Pattern:** +- Regular Fider code continues using `postgres` package as before +- Commercial/premium code can import and use `dbEntities.User` directly +- No changes needed to existing `postgres` code + +## Converting Additional Types + +To export another type for commercial use: + +1. Open the corresponding file (e.g., `comment.go`, `post.go`) +2. Capitalize the struct name: `type comment` → `type Comment` +3. Capitalize the method: `func (c *comment) toModel` → `func (c *Comment) ToModel` +4. Update any internal references if needed + +## Testing + +The package includes unit tests for the exported User type. Run them with: + +```bash +godotenv -f .test.env go test ./app/services/sqlstore/dbEntities/... +``` \ No newline at end of file diff --git a/app/services/sqlstore/dbEntities/billing.go b/app/services/sqlstore/dbEntities/billing.go new file mode 100644 index 000000000..295078e37 --- /dev/null +++ b/app/services/sqlstore/dbEntities/billing.go @@ -0,0 +1,12 @@ +package dbEntities + +import ( + "github.com/getfider/fider/app/pkg/dbx" +) + +type StripeBillingState struct { + StripeCustomerID dbx.NullString `db:"stripe_customer_id"` + StripeSubscriptionID dbx.NullString `db:"stripe_subscription_id"` + LicenseKey dbx.NullString `db:"license_key"` + PaddleSubscriptionID dbx.NullString `db:"paddle_subscription_id"` +} diff --git a/app/services/sqlstore/dbEntities/comment.go b/app/services/sqlstore/dbEntities/comment.go new file mode 100644 index 000000000..3f3a4611a --- /dev/null +++ b/app/services/sqlstore/dbEntities/comment.go @@ -0,0 +1,42 @@ +package dbEntities + +import ( + "context" + "encoding/json" + "time" + + "github.com/getfider/fider/app/models/entity" + "github.com/getfider/fider/app/pkg/dbx" +) + +type Comment struct { + ID int `db:"id"` + Content string `db:"content"` + CreatedAt time.Time `db:"created_at"` + User *User `db:"user"` + Attachments []string `db:"attachment_bkeys"` + EditedAt dbx.NullTime `db:"edited_at"` + EditedBy *User `db:"edited_by"` + ReactionCounts dbx.NullString `db:"reaction_counts"` + IsApproved bool `db:"is_approved"` +} + +func (c *Comment) ToModel(ctx context.Context) *entity.Comment { + comment := &entity.Comment{ + ID: c.ID, + Content: c.Content, + CreatedAt: c.CreatedAt, + User: c.User.ToModel(ctx), + Attachments: c.Attachments, + IsApproved: c.IsApproved, + } + if c.EditedAt.Valid { + comment.EditedBy = c.EditedBy.ToModel(ctx) + comment.EditedAt = &c.EditedAt.Time + } + + if c.ReactionCounts.Valid { + _ = json.Unmarshal([]byte(c.ReactionCounts.String), &comment.ReactionCounts) + } + return comment +} diff --git a/app/services/sqlstore/dbEntities/oauth.go b/app/services/sqlstore/dbEntities/oauth.go new file mode 100644 index 000000000..32c973af1 --- /dev/null +++ b/app/services/sqlstore/dbEntities/oauth.go @@ -0,0 +1,43 @@ +package dbEntities + +import ( + "github.com/getfider/fider/app/models/entity" +) + +type OAuthConfig struct { + ID int `db:"id"` + Provider string `db:"provider"` + DisplayName string `db:"display_name"` + LogoBlobKey string `db:"logo_bkey"` + Status int `db:"status"` + IsTrusted bool `db:"is_trusted"` + ClientID string `db:"client_id"` + ClientSecret string `db:"client_secret"` + AuthorizeURL string `db:"authorize_url"` + TokenURL string `db:"token_url"` + Scope string `db:"scope"` + ProfileURL string `db:"profile_url"` + JSONUserIDPath string `db:"json_user_id_path"` + JSONUserNamePath string `db:"json_user_name_path"` + JSONUserEmailPath string `db:"json_user_email_path"` +} + +func (m *OAuthConfig) ToModel() *entity.OAuthConfig { + return &entity.OAuthConfig{ + ID: m.ID, + Provider: m.Provider, + DisplayName: m.DisplayName, + Status: m.Status, + IsTrusted: m.IsTrusted, + LogoBlobKey: m.LogoBlobKey, + ClientID: m.ClientID, + ClientSecret: m.ClientSecret, + AuthorizeURL: m.AuthorizeURL, + TokenURL: m.TokenURL, + ProfileURL: m.ProfileURL, + Scope: m.Scope, + JSONUserIDPath: m.JSONUserIDPath, + JSONUserNamePath: m.JSONUserNamePath, + JSONUserEmailPath: m.JSONUserEmailPath, + } +} diff --git a/app/services/sqlstore/dbEntities/post.go b/app/services/sqlstore/dbEntities/post.go new file mode 100644 index 000000000..b600f42dd --- /dev/null +++ b/app/services/sqlstore/dbEntities/post.go @@ -0,0 +1,74 @@ +package dbEntities + +import ( + "context" + "time" + + "github.com/getfider/fider/app/models/entity" + "github.com/getfider/fider/app/models/enum" + "github.com/getfider/fider/app/pkg/dbx" + "github.com/lib/pq" +) + +type Post struct { + ID int `db:"id"` + Number int `db:"number"` + Title string `db:"title"` + Slug string `db:"slug"` + Description string `db:"description"` + CreatedAt time.Time `db:"created_at"` + Search []byte `db:"search"` + User *User `db:"user"` + HasVoted bool `db:"has_voted"` + VotesCount int `db:"votes_count"` + CommentsCount int `db:"comments_count"` + RecentVotes int `db:"recent_votes_count"` + RecentComments int `db:"recent_comments_count"` + Status int `db:"status"` + Response dbx.NullString `db:"response"` + RespondedAt dbx.NullTime `db:"response_date"` + ResponseUser *User `db:"response_user"` + OriginalNumber dbx.NullInt `db:"original_number"` + OriginalTitle dbx.NullString `db:"original_title"` + OriginalSlug dbx.NullString `db:"original_slug"` + OriginalStatus dbx.NullInt `db:"original_status"` + Tags pq.StringArray `db:"tags"` + IsApproved bool `db:"is_approved"` +} + +func (i *Post) ToModel(ctx context.Context) *entity.Post { + post := &entity.Post{ + ID: i.ID, + Number: i.Number, + Title: i.Title, + Slug: i.Slug, + Description: i.Description, + CreatedAt: i.CreatedAt, + User: i.User.ToModel(ctx), + HasVoted: i.HasVoted, + VotesCount: i.VotesCount, + CommentsCount: i.CommentsCount, + Status: enum.PostStatus(i.Status), + Tags: i.Tags, + IsApproved: i.IsApproved, + } + + if i.Response.Valid { + post.Response = &entity.PostResponse{ + Text: i.Response.String, + RespondedAt: i.RespondedAt.Time, + User: i.ResponseUser.ToModel(ctx), + } + + if i.OriginalNumber.Valid { + post.Response.Original = &entity.OriginalPost{ + Number: int(i.OriginalNumber.Int64), + Title: i.OriginalTitle.String, + Slug: i.OriginalSlug.String, + Status: enum.PostStatus(i.OriginalStatus.Int64), + } + } + } + + return post +} diff --git a/app/services/sqlstore/dbEntities/tag.go b/app/services/sqlstore/dbEntities/tag.go new file mode 100644 index 000000000..ca13660cd --- /dev/null +++ b/app/services/sqlstore/dbEntities/tag.go @@ -0,0 +1,23 @@ +package dbEntities + +import ( + "github.com/getfider/fider/app/models/entity" +) + +type Tag struct { + ID int `db:"id"` + Name string `db:"name"` + Slug string `db:"slug"` + Color string `db:"color"` + IsPublic bool `db:"is_public"` +} + +func (t *Tag) ToModel() *entity.Tag { + return &entity.Tag{ + ID: t.ID, + Name: t.Name, + Slug: t.Slug, + Color: t.Color, + IsPublic: t.IsPublic, + } +} diff --git a/app/services/sqlstore/dbEntities/tenant.go b/app/services/sqlstore/dbEntities/tenant.go new file mode 100644 index 000000000..71243284a --- /dev/null +++ b/app/services/sqlstore/dbEntities/tenant.go @@ -0,0 +1,66 @@ +package dbEntities + +import ( + "github.com/getfider/fider/app/models/entity" + "github.com/getfider/fider/app/models/enum" + "github.com/getfider/fider/app/pkg/env" + "github.com/getfider/fider/app/services" +) + +type Tenant struct { + ID int `db:"id"` + Name string `db:"name"` + Subdomain string `db:"subdomain"` + CNAME string `db:"cname"` + Invitation string `db:"invitation"` + WelcomeMessage string `db:"welcome_message"` + Status int `db:"status"` + Locale string `db:"locale"` + IsPrivate bool `db:"is_private"` + LogoBlobKey string `db:"logo_bkey"` + CustomCSS string `db:"custom_css"` + AllowedSchemes string `db:"allowed_schemes"` + IsEmailAuthAllowed bool `db:"is_email_auth_allowed"` + IsFeedEnabled bool `db:"is_feed_enabled"` + PreventIndexing bool `db:"prevent_indexing"` + IsModerationEnabled bool `db:"is_moderation_enabled"` + IsPro bool `db:"is_pro"` +} + +func (t *Tenant) ToModel() *entity.Tenant { + if t == nil { + return nil + } + + // Compute isCommercial based on hosting mode + var isCommercial bool + if env.IsSingleHostMode() { + // Self-hosted: check if license service validated successfully + isCommercial = services.IsCommercialFeatureEnabled(services.FeatureContentModeration) + } else { + // Hosted multi-tenant: check if this tenant has Pro subscription + isCommercial = t.IsPro + } + + tenant := &entity.Tenant{ + ID: t.ID, + Name: t.Name, + Subdomain: t.Subdomain, + CNAME: t.CNAME, + Invitation: t.Invitation, + WelcomeMessage: t.WelcomeMessage, + Status: enum.TenantStatus(t.Status), + Locale: t.Locale, + IsPrivate: t.IsPrivate, + LogoBlobKey: t.LogoBlobKey, + CustomCSS: t.CustomCSS, + AllowedSchemes: t.AllowedSchemes, + IsEmailAuthAllowed: t.IsEmailAuthAllowed, + IsFeedEnabled: t.IsFeedEnabled, + PreventIndexing: t.PreventIndexing, + IsModerationEnabled: t.IsModerationEnabled, + IsCommercial: isCommercial, + } + + return tenant +} diff --git a/app/services/sqlstore/dbEntities/user.go b/app/services/sqlstore/dbEntities/user.go new file mode 100644 index 000000000..b255f3ca5 --- /dev/null +++ b/app/services/sqlstore/dbEntities/user.go @@ -0,0 +1,91 @@ +package dbEntities + +import ( + "context" + "database/sql" + "net/url" + + "github.com/getfider/fider/app" + "github.com/getfider/fider/app/models/entity" + "github.com/getfider/fider/app/models/enum" + "github.com/getfider/fider/app/pkg/web" +) + +// User is the database mapping for users table +type User struct { + ID sql.NullInt64 `db:"id"` + Name sql.NullString `db:"name"` + Email sql.NullString `db:"email"` + Tenant *Tenant `db:"tenant"` + Role sql.NullInt64 `db:"role"` + Status sql.NullInt64 `db:"status"` + AvatarType sql.NullInt64 `db:"avatar_type"` + AvatarBlobKey sql.NullString `db:"avatar_bkey"` + IsTrusted sql.NullBool `db:"is_trusted"` + Providers []*UserProvider +} + +type UserProvider struct { + Name sql.NullString `db:"provider"` + UID sql.NullString `db:"provider_uid"` +} + +type UserSetting struct { + Key string `db:"key"` + Value string `db:"value"` +} + +func (u *User) ToModel(ctx context.Context) *entity.User { + if u == nil { + return nil + } + + tenant, ok := ctx.Value(app.TenantCtxKey).(*entity.Tenant) + if !ok || tenant == nil { + if u.Tenant != nil { + tenant = u.Tenant.ToModel() + } + } + + avatarType := enum.AvatarType(u.AvatarType.Int64) + avatarURL := "" + if u.AvatarType.Valid { + avatarURL = buildAvatarURL(ctx, avatarType, int(u.ID.Int64), u.Name.String, u.AvatarBlobKey.String) + } + + user := &entity.User{ + ID: int(u.ID.Int64), + Name: u.Name.String, + Email: u.Email.String, + Tenant: tenant, + Role: enum.Role(u.Role.Int64), + Status: enum.UserStatus(u.Status.Int64), + AvatarType: avatarType, + AvatarBlobKey: u.AvatarBlobKey.String, + AvatarURL: avatarURL, + IsTrusted: u.IsTrusted.Bool, + } + + if u.Providers != nil { + user.Providers = make([]*entity.UserProvider, len(u.Providers)) + for i, p := range u.Providers { + user.Providers[i] = &entity.UserProvider{ + Name: p.Name.String, + UID: p.UID.String, + } + } + } + + return user +} + +func buildAvatarURL(ctx context.Context, avatarType enum.AvatarType, id int, name, avatarBlobKey string) string { + if name == "" { + name = "-" + } + + if avatarType == enum.AvatarTypeCustom { + return web.AssetsURL(ctx, "/static/images/%s", avatarBlobKey) + } + return web.AssetsURL(ctx, "/static/avatars/%s/%d/%s", avatarType.String(), id, url.PathEscape(name)) +} diff --git a/app/services/sqlstore/dbEntities/user_test.go b/app/services/sqlstore/dbEntities/user_test.go new file mode 100644 index 000000000..5f6a8ea7b --- /dev/null +++ b/app/services/sqlstore/dbEntities/user_test.go @@ -0,0 +1,91 @@ +package dbEntities_test + +import ( + "context" + "database/sql" + "net/url" + "testing" + + "github.com/getfider/fider/app" + "github.com/getfider/fider/app/models/enum" + "github.com/getfider/fider/app/pkg/web" + "github.com/getfider/fider/app/services/sqlstore/dbEntities" +) + +func TestUserToModel(t *testing.T) { + // Create a proper context with web.Request + u, _ := url.Parse("http://test.fider.io") + req := web.Request{URL: u} + ctx := context.WithValue(context.Background(), app.RequestCtxKey, req) + + // Create a test dbEntities.User + dbUser := &dbEntities.User{ + ID: sql.NullInt64{Int64: 1, Valid: true}, + Name: sql.NullString{String: "John Doe", Valid: true}, + Email: sql.NullString{String: "john@example.com", Valid: true}, + Role: sql.NullInt64{Int64: int64(enum.RoleAdministrator), Valid: true}, + Status: sql.NullInt64{Int64: int64(enum.UserActive), Valid: true}, + AvatarType: sql.NullInt64{Int64: int64(enum.AvatarTypeGravatar), Valid: true}, + AvatarBlobKey: sql.NullString{String: "", Valid: true}, + IsTrusted: sql.NullBool{Bool: true, Valid: true}, + Providers: []*dbEntities.UserProvider{ + { + Name: sql.NullString{String: "google", Valid: true}, + UID: sql.NullString{String: "123456", Valid: true}, + }, + }, + } + + // Convert to entity.User + entityUser := dbUser.ToModel(ctx) + + // Verify conversion + if entityUser == nil { + t.Fatal("ToModel returned nil") + } + + if entityUser.ID != 1 { + t.Errorf("Expected ID 1, got %d", entityUser.ID) + } + + if entityUser.Name != "John Doe" { + t.Errorf("Expected Name 'John Doe', got '%s'", entityUser.Name) + } + + if entityUser.Email != "john@example.com" { + t.Errorf("Expected Email 'john@example.com', got '%s'", entityUser.Email) + } + + if entityUser.Role != enum.RoleAdministrator { + t.Errorf("Expected Role Administrator, got %v", entityUser.Role) + } + + if entityUser.Status != enum.UserActive { + t.Errorf("Expected Status Active, got %v", entityUser.Status) + } + + if !entityUser.IsTrusted { + t.Error("Expected IsTrusted to be true") + } + + if len(entityUser.Providers) != 1 { + t.Fatalf("Expected 1 provider, got %d", len(entityUser.Providers)) + } + + if entityUser.Providers[0].Name != "google" { + t.Errorf("Expected provider name 'google', got '%s'", entityUser.Providers[0].Name) + } + + if entityUser.Providers[0].UID != "123456" { + t.Errorf("Expected provider UID '123456', got '%s'", entityUser.Providers[0].UID) + } +} + +func TestUserToModel_Nil(t *testing.T) { + var dbUser *dbEntities.User + entityUser := dbUser.ToModel(context.Background()) + + if entityUser != nil { + t.Error("Expected ToModel on nil to return nil") + } +} diff --git a/app/services/sqlstore/dbEntities/verification.go b/app/services/sqlstore/dbEntities/verification.go new file mode 100644 index 000000000..be01b5d66 --- /dev/null +++ b/app/services/sqlstore/dbEntities/verification.go @@ -0,0 +1,43 @@ +package dbEntities + +import ( + "time" + + "github.com/getfider/fider/app/models/entity" + "github.com/getfider/fider/app/models/enum" + "github.com/getfider/fider/app/pkg/dbx" +) + +type EmailVerification struct { + ID int `db:"id"` + Name string `db:"name"` + Email string `db:"email"` + Key string `db:"key"` + Kind enum.EmailVerificationKind `db:"kind"` + UserID dbx.NullInt `db:"user_id"` + CreatedAt time.Time `db:"created_at"` + ExpiresAt time.Time `db:"expires_at"` + VerifiedAt dbx.NullTime `db:"verified_at"` +} + +func (t *EmailVerification) ToModel() *entity.EmailVerification { + model := &entity.EmailVerification{ + Name: t.Name, + Email: t.Email, + Key: t.Key, + Kind: t.Kind, + CreatedAt: t.CreatedAt, + ExpiresAt: t.ExpiresAt, + VerifiedAt: nil, + } + + if t.VerifiedAt.Valid { + model.VerifiedAt = &t.VerifiedAt.Time + } + + if t.UserID.Valid { + model.UserID = int(t.UserID.Int64) + } + + return model +} diff --git a/app/services/sqlstore/dbEntities/vote.go b/app/services/sqlstore/dbEntities/vote.go new file mode 100644 index 000000000..6c1d12df9 --- /dev/null +++ b/app/services/sqlstore/dbEntities/vote.go @@ -0,0 +1,33 @@ +package dbEntities + +import ( + "context" + "time" + + "github.com/getfider/fider/app/models/entity" + "github.com/getfider/fider/app/models/enum" +) + +type Vote struct { + User *struct { + ID int `db:"id"` + Name string `db:"name"` + Email string `db:"email"` + AvatarType int64 `db:"avatar_type"` + AvatarBlobKey string `db:"avatar_bkey"` + } `db:"user"` + CreatedAt time.Time `db:"created_at"` +} + +func (v *Vote) ToModel(ctx context.Context) *entity.Vote { + vote := &entity.Vote{ + CreatedAt: v.CreatedAt, + User: &entity.VoteUser{ + ID: v.User.ID, + Name: v.User.Name, + Email: v.User.Email, + AvatarURL: buildAvatarURL(ctx, enum.AvatarType(v.User.AvatarType), v.User.ID, v.User.Name, v.User.AvatarBlobKey), + }, + } + return vote +} diff --git a/app/services/sqlstore/postgres/billing.go b/app/services/sqlstore/postgres/billing.go index 891de2b49..0922f6a07 100644 --- a/app/services/sqlstore/postgres/billing.go +++ b/app/services/sqlstore/postgres/billing.go @@ -2,177 +2,119 @@ package postgres import ( "context" - "time" + "github.com/getfider/fider/app" "github.com/getfider/fider/app/models/cmd" "github.com/getfider/fider/app/models/entity" "github.com/getfider/fider/app/models/enum" "github.com/getfider/fider/app/models/query" "github.com/getfider/fider/app/pkg/dbx" "github.com/getfider/fider/app/pkg/errors" - "github.com/lib/pq" + "github.com/getfider/fider/app/services/sqlstore/dbEntities" ) -type dbBillingState struct { - Status int `db:"status"` - PlanID string `db:"paddle_plan_id"` - SubscriptionID string `db:"paddle_subscription_id"` - TrialEndsAt dbx.NullTime `db:"trial_ends_at"` - SubscriptionEndsAt dbx.NullTime `db:"subscription_ends_at"` -} - -func (s *dbBillingState) toModel(ctx context.Context) *entity.BillingState { - model := &entity.BillingState{ - Status: enum.BillingStatus(s.Status), - PlanID: s.PlanID, - SubscriptionID: s.SubscriptionID, - } +func activateBillingSubscription(ctx context.Context, c *cmd.ActivateBillingSubscription) error { + return using(ctx, func(trx *dbx.Trx, _ *entity.Tenant, _ *entity.User) error { + _, err := trx.Execute(` + UPDATE tenants + SET is_pro = true, status = $2 + WHERE id = $1 + `, c.TenantID, enum.TenantActive) + if err != nil { + return errors.Wrap(err, "failed activate tenant") + } - if s.TrialEndsAt.Valid { - model.TrialEndsAt = &s.TrialEndsAt.Time - } + return nil + }) +} - if s.SubscriptionEndsAt.Valid { - model.SubscriptionEndsAt = &s.SubscriptionEndsAt.Time - } +func cancelBillingSubscription(ctx context.Context, c *cmd.CancelBillingSubscription) error { + return using(ctx, func(trx *dbx.Trx, _ *entity.Tenant, _ *entity.User) error { + _, err := trx.Execute(` + UPDATE tenants + SET is_pro = false + WHERE id = $1 + `, c.TenantID) + if err != nil { + return errors.Wrap(err, "failed to set tenant to free plan") + } - return model + return nil + }) } -func getBillingState(ctx context.Context, q *query.GetBillingState) error { +func getStripeBillingState(ctx context.Context, q *query.GetStripeBillingState) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { - q.Result = nil - - state := dbBillingState{} + state := dbEntities.StripeBillingState{} err := trx.Get(&state, - `SELECT - trial_ends_at, - subscription_ends_at, - paddle_subscription_id, - paddle_plan_id, - status + `SELECT stripe_customer_id, stripe_subscription_id, license_key, paddle_subscription_id FROM tenants_billing WHERE tenant_id = $1`, tenant.ID) if err != nil { + if errors.Cause(err) == app.ErrNotFound { + // No billing record for this tenant, return empty state + q.Result = &entity.StripeBillingState{} + return nil + } return err } - q.Result = state.toModel(ctx) + q.Result = &entity.StripeBillingState{ + CustomerID: state.StripeCustomerID.String, + SubscriptionID: state.StripeSubscriptionID.String, + LicenseKey: state.LicenseKey.String, + PaddleSubscriptionID: state.PaddleSubscriptionID.String, + } return nil }) } -func activateBillingSubscription(ctx context.Context, c *cmd.ActivateBillingSubscription) error { +func activateStripeSubscription(ctx context.Context, c *cmd.ActivateStripeSubscription) error { return using(ctx, func(trx *dbx.Trx, _ *entity.Tenant, _ *entity.User) error { _, err := trx.Execute(` - UPDATE tenants_billing - SET subscription_ends_at = null, paddle_subscription_id = $2, paddle_plan_id = $3, status = $4 - WHERE tenant_id = $1 - `, c.TenantID, c.SubscriptionID, c.PlanID, enum.BillingActive) + INSERT INTO tenants_billing (tenant_id, stripe_customer_id, stripe_subscription_id, license_key) + VALUES ($1, $2, $3, $4) + ON CONFLICT (tenant_id) DO UPDATE + SET stripe_customer_id = $2, stripe_subscription_id = $3, license_key = $4 + `, c.TenantID, c.CustomerID, c.SubscriptionID, c.LicenseKey) if err != nil { - return errors.Wrap(err, "failed activate billing subscription") + return errors.Wrap(err, "failed to activate stripe subscription") } _, err = trx.Execute(` UPDATE tenants - SET status = $2 + SET is_pro = true WHERE id = $1 - `, c.TenantID, enum.TenantActive) + `, c.TenantID) if err != nil { - return errors.Wrap(err, "failed activate tenant") + return errors.Wrap(err, "failed to set tenant to pro plan") } return nil }) } -func cancelBillingSubscription(ctx context.Context, c *cmd.CancelBillingSubscription) error { +func cancelStripeSubscription(ctx context.Context, c *cmd.CancelStripeSubscription) error { return using(ctx, func(trx *dbx.Trx, _ *entity.Tenant, _ *entity.User) error { _, err := trx.Execute(` UPDATE tenants_billing - SET subscription_ends_at = $2, status = $3 + SET stripe_subscription_id = NULL WHERE tenant_id = $1 - `, c.TenantID, c.SubscriptionEndsAt, enum.BillingCancelled) - if err != nil { - return errors.Wrap(err, "failed cancel billing subscription") - } - return nil - }) -} - -func lockExpiredTenants(ctx context.Context, c *cmd.LockExpiredTenants) error { - return using(ctx, func(trx *dbx.Trx, _ *entity.Tenant, _ *entity.User) error { - now := time.Now() - - type tenant struct { - Id int `db:"id"` - } - - tenants := []*tenant{} - err := trx.Select(&tenants, ` - SELECT id - FROM tenants t - INNER JOIN tenants_billing tb - ON t.id = tb.tenant_id - WHERE t.status <> $1 AND t.status <> $2 - AND ( - (tb.status = $3 AND trial_ends_at <= $5) - OR (tb.status = $4 AND subscription_ends_at <= $5) - )`, enum.TenantLocked, enum.TenantDisabled, enum.BillingTrial, enum.BillingCancelled, now) + `, c.TenantID) if err != nil { - return errors.Wrap(err, "failed to get expired trial/cancelled tenants") - } - - if len(tenants) > 0 { - ids := make([]int, 0) - for _, tenant := range tenants { - ids = append(ids, tenant.Id) - } - - count, err := trx.Execute(` - UPDATE tenants - SET status = $1 - WHERE id = ANY($2) - `, enum.TenantLocked, pq.Array(ids)) - if err != nil { - return errors.Wrap(err, "failed to lock trial/cancelled tenants") - } - - c.NumOfTenantsLocked = count - c.TenantsLocked = ids + return errors.Wrap(err, "failed to cancel stripe subscription") } - return nil - }) -} - -func getTrialingTenantContacts(ctx context.Context, q *query.GetTrialingTenantContacts) error { - return using(ctx, func(trx *dbx.Trx, _ *entity.Tenant, _ *entity.User) error { - var users []*dbUser - err := trx.Select(&users, ` - SELECT - u.name, - u.email, - u.role, - u.status, - t.subdomain as tenant_subdomain - FROM tenants_billing tb - INNER JOIN tenants t - ON t.id = tb.tenant_id - INNER JOIN users u - ON u.tenant_id = tb.tenant_id - AND u.role = $1 - WHERE date(trial_ends_at) = date($2) - AND tb.status = $3`, enum.RoleAdministrator, q.TrialExpiresOn, enum.BillingTrial) + _, err = trx.Execute(` + UPDATE tenants + SET is_pro = false + WHERE id = $1 + `, c.TenantID) if err != nil { - return errors.Wrap(err, "failed to get trialing tenant contacts") + return errors.Wrap(err, "failed to set tenant to free plan") } - q.Contacts = make([]*entity.User, len(users)) - for i, user := range users { - q.Contacts[i] = user.toModel(ctx) - } return nil }) } diff --git a/app/services/sqlstore/postgres/billing_test.go b/app/services/sqlstore/postgres/billing_test.go deleted file mode 100644 index a34445b42..000000000 --- a/app/services/sqlstore/postgres/billing_test.go +++ /dev/null @@ -1,24 +0,0 @@ -package postgres_test - -import ( - "testing" - - "github.com/getfider/fider/app/models/cmd" - - . "github.com/getfider/fider/app/pkg/assert" - "github.com/getfider/fider/app/pkg/bus" -) - -func TestLockExpiredTenants_ShouldTriggerForOneTenant(t *testing.T) { - ctx := SetupDatabaseTest(t) - defer TeardownDatabaseTest() - - // There is a tenant with an expired trial setup in the seed for the test database. - q := &cmd.LockExpiredTenants{} - - err := bus.Dispatch(ctx, q) - Expect(err).IsNil() - Expect(q.NumOfTenantsLocked).Equals(int64(1)) - Expect(q.TenantsLocked).Equals([]int{3}) - -} diff --git a/app/services/sqlstore/postgres/comment.go b/app/services/sqlstore/postgres/comment.go index eaaa17d73..1a2e6d256 100644 --- a/app/services/sqlstore/postgres/comment.go +++ b/app/services/sqlstore/postgres/comment.go @@ -2,7 +2,7 @@ package postgres import ( "context" - "encoding/json" + "fmt" "time" "github.com/getfider/fider/app/models/cmd" @@ -10,46 +10,19 @@ import ( "github.com/getfider/fider/app/models/query" "github.com/getfider/fider/app/pkg/dbx" "github.com/getfider/fider/app/pkg/errors" + "github.com/getfider/fider/app/services/sqlstore/dbEntities" ) -type dbComment struct { - ID int `db:"id"` - Content string `db:"content"` - CreatedAt time.Time `db:"created_at"` - User *dbUser `db:"user"` - Attachments []string `db:"attachment_bkeys"` - EditedAt dbx.NullTime `db:"edited_at"` - EditedBy *dbUser `db:"edited_by"` - ReactionCounts dbx.NullString `db:"reaction_counts"` -} - -func (c *dbComment) toModel(ctx context.Context) *entity.Comment { - comment := &entity.Comment{ - ID: c.ID, - Content: c.Content, - CreatedAt: c.CreatedAt, - User: c.User.toModel(ctx), - Attachments: c.Attachments, - } - if c.EditedAt.Valid { - comment.EditedBy = c.EditedBy.toModel(ctx) - comment.EditedAt = &c.EditedAt.Time - } - - if c.ReactionCounts.Valid { - _ = json.Unmarshal([]byte(c.ReactionCounts.String), &comment.ReactionCounts) - } - return comment -} func addNewComment(ctx context.Context, c *cmd.AddNewComment) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + isApproved := !tenant.IsModerationEnabled || user.IsCollaborator() var id int if err := trx.Get(&id, ` - INSERT INTO comments (tenant_id, post_id, content, user_id, created_at) - VALUES ($1, $2, $3, $4, $5) + INSERT INTO comments (tenant_id, post_id, content, user_id, created_at, is_approved) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id - `, tenant.ID, c.Post.ID, c.Content, user.ID, time.Now()); err != nil { + `, tenant.ID, c.Post.ID, c.Content, user.ID, time.Now(), isApproved); err != nil { return errors.Wrap(err, "failed add new comment") } @@ -123,12 +96,13 @@ func getCommentByID(ctx context.Context, q *query.GetCommentByID) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { q.Result = nil - comment := dbComment{} + comment := dbEntities.Comment{} err := trx.Get(&comment, `SELECT c.id, c.content, c.created_at, c.edited_at, + c.is_approved, u.id AS user_id, u.name AS user_name, u.email AS user_email, @@ -158,7 +132,7 @@ func getCommentByID(ctx context.Context, q *query.GetCommentByID) error { return err } - q.Result = comment.toModel(ctx) + q.Result = comment.ToModel(ctx) return nil }) } @@ -167,13 +141,26 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { q.Result = make([]*entity.Comment, 0) - comments := []*dbComment{} + comments := []*dbEntities.Comment{} userId := 0 if user != nil { userId = user.ID } - err := trx.Select(&comments, - ` + + // Build approval filter based on user permissions + approvalFilter := "" + if user != nil && user.IsCollaborator() { + // Admins and collaborators can see all comments + approvalFilter = "" + } else if user != nil { + // Regular users can see approved comments + their own unapproved comments + approvalFilter = fmt.Sprintf(" AND (c.is_approved = true OR c.user_id = %d)", user.ID) + } else { + // Anonymous users can only see approved comments + approvalFilter = " AND c.is_approved = true" + } + + query := fmt.Sprintf(` WITH agg_attachments AS ( SELECT c.id as comment_id, @@ -212,6 +199,7 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { c.content, c.created_at, c.edited_at, + c.is_approved, u.id AS user_id, u.name AS user_name, u.email AS user_email, @@ -244,15 +232,17 @@ func getCommentsByPost(ctx context.Context, q *query.GetCommentsByPost) error { ON ar.comment_id = c.id WHERE p.id = $1 AND p.tenant_id = $2 - AND c.deleted_at IS NULL - ORDER BY c.created_at ASC`, q.Post.ID, tenant.ID, userId) + AND c.deleted_at IS NULL%s + ORDER BY c.created_at ASC`, approvalFilter) + + err := trx.Select(&comments, query, q.Post.ID, tenant.ID, userId) if err != nil { return errors.Wrap(err, "failed get comments of post with id '%d'", q.Post.ID) } q.Result = make([]*entity.Comment, len(comments)) for i, comment := range comments { - q.Result[i] = comment.toModel(ctx) + q.Result[i] = comment.ToModel(ctx) } return nil }) diff --git a/app/services/sqlstore/postgres/moderation.go b/app/services/sqlstore/postgres/moderation.go new file mode 100644 index 000000000..e4a76d3d4 --- /dev/null +++ b/app/services/sqlstore/postgres/moderation.go @@ -0,0 +1,49 @@ +package postgres + +import ( + "context" + + "github.com/getfider/fider/app/models/cmd" + "github.com/getfider/fider/app/models/query" + "github.com/getfider/fider/app/pkg/errors" +) + +// Stub functions - commercial license required for content moderation + +func approvePost(ctx context.Context, c *cmd.ApprovePost) error { + return errors.New("Content moderation requires commercial license") +} + +func declinePost(ctx context.Context, c *cmd.DeclinePost) error { + return errors.New("Content moderation requires commercial license") +} + +func approveComment(ctx context.Context, c *cmd.ApproveComment) error { + return errors.New("Content moderation requires commercial license") +} + +func declineComment(ctx context.Context, c *cmd.DeclineComment) error { + return errors.New("Content moderation requires commercial license") +} + +func bulkApproveItems(ctx context.Context, c *cmd.BulkApproveItems) error { + return errors.New("Content moderation requires commercial license") +} + +func bulkDeclineItems(ctx context.Context, c *cmd.BulkDeclineItems) error { + return errors.New("Content moderation requires commercial license") +} + +func getModerationItems(ctx context.Context, q *query.GetModerationItems) error { + q.Result = make([]*query.ModerationItem, 0) + return nil +} + +func getModerationCount(ctx context.Context, q *query.GetModerationCount) error { + q.Result = 0 + return nil +} + +func trustUser(ctx context.Context, c *cmd.TrustUser) error { + return errors.New("Content moderation requires commercial license") +} \ No newline at end of file diff --git a/app/services/sqlstore/postgres/notification.go b/app/services/sqlstore/postgres/notification.go index f5f724b10..af80a7e5c 100644 --- a/app/services/sqlstore/postgres/notification.go +++ b/app/services/sqlstore/postgres/notification.go @@ -12,6 +12,7 @@ import ( "github.com/getfider/fider/app/models/query" "github.com/getfider/fider/app/pkg/dbx" "github.com/getfider/fider/app/pkg/errors" + "github.com/getfider/fider/app/services/sqlstore/dbEntities" "github.com/lib/pq" ) @@ -177,7 +178,7 @@ func getActiveSubscribers(ctx context.Context, q *query.GetActiveSubscribers) er q.Result = make([]*entity.User, 0) var ( - users []*dbUser + users []*dbEntities.User err error ) @@ -249,7 +250,7 @@ func getActiveSubscribers(ctx context.Context, q *query.GetActiveSubscribers) er q.Result = make([]*entity.User, len(users)) for i, user := range users { - q.Result[i] = user.toModel(ctx) + q.Result[i] = user.ToModel(ctx) } return nil }) diff --git a/app/services/sqlstore/postgres/oauth.go b/app/services/sqlstore/postgres/oauth.go index 55b9b91d7..16bca0964 100644 --- a/app/services/sqlstore/postgres/oauth.go +++ b/app/services/sqlstore/postgres/oauth.go @@ -10,53 +10,16 @@ import ( "github.com/getfider/fider/app/models/query" "github.com/getfider/fider/app/pkg/dbx" "github.com/getfider/fider/app/pkg/errors" + "github.com/getfider/fider/app/services/sqlstore/dbEntities" ) -type dbOAuthConfig struct { - ID int `db:"id"` - Provider string `db:"provider"` - DisplayName string `db:"display_name"` - LogoBlobKey string `db:"logo_bkey"` - Status int `db:"status"` - IsTrusted bool `db:"is_trusted"` - ClientID string `db:"client_id"` - ClientSecret string `db:"client_secret"` - AuthorizeURL string `db:"authorize_url"` - TokenURL string `db:"token_url"` - Scope string `db:"scope"` - ProfileURL string `db:"profile_url"` - JSONUserIDPath string `db:"json_user_id_path"` - JSONUserNamePath string `db:"json_user_name_path"` - JSONUserEmailPath string `db:"json_user_email_path"` -} - -func (m *dbOAuthConfig) toModel() *entity.OAuthConfig { - return &entity.OAuthConfig{ - ID: m.ID, - Provider: m.Provider, - DisplayName: m.DisplayName, - Status: m.Status, - IsTrusted: m.IsTrusted, - LogoBlobKey: m.LogoBlobKey, - ClientID: m.ClientID, - ClientSecret: m.ClientSecret, - AuthorizeURL: m.AuthorizeURL, - TokenURL: m.TokenURL, - ProfileURL: m.ProfileURL, - Scope: m.Scope, - JSONUserIDPath: m.JSONUserIDPath, - JSONUserNamePath: m.JSONUserNamePath, - JSONUserEmailPath: m.JSONUserEmailPath, - } -} - func getCustomOAuthConfigByProvider(ctx context.Context, q *query.GetCustomOAuthConfigByProvider) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { if tenant == nil { return app.ErrNotFound } - config := &dbOAuthConfig{} + config := &dbEntities.OAuthConfig{} err := trx.Get(config, ` SELECT id, provider, display_name, status, is_trusted, logo_bkey, client_id, client_secret, authorize_url, @@ -69,7 +32,7 @@ func getCustomOAuthConfigByProvider(ctx context.Context, q *query.GetCustomOAuth return err } - q.Result = config.toModel() + q.Result = config.ToModel() return nil }) } @@ -80,7 +43,7 @@ func listCustomOAuthConfig(ctx context.Context, q *query.ListCustomOAuthConfig) return nil } - configs := []*dbOAuthConfig{} + configs := []*dbEntities.OAuthConfig{} if tenant != nil { err := trx.Select(&configs, ` SELECT id, provider, display_name, status, is_trusted, logo_bkey, @@ -97,7 +60,7 @@ func listCustomOAuthConfig(ctx context.Context, q *query.ListCustomOAuthConfig) q.Result = make([]*entity.OAuthConfig, len(configs)) for i, config := range configs { - q.Result[i] = config.toModel() + q.Result[i] = config.ToModel() } return nil }) diff --git a/app/services/sqlstore/postgres/post.go b/app/services/sqlstore/postgres/post.go index c7ead685e..ed01419b3 100644 --- a/app/services/sqlstore/postgres/post.go +++ b/app/services/sqlstore/postgres/post.go @@ -2,7 +2,6 @@ package postgres import ( "context" - "database/sql" "fmt" "strconv" "strings" @@ -22,72 +21,14 @@ import ( "github.com/getfider/fider/app/models/cmd" "github.com/getfider/fider/app/pkg/dbx" "github.com/getfider/fider/app/pkg/errors" + "github.com/getfider/fider/app/services/sqlstore/dbEntities" ) -type dbPost struct { - ID int `db:"id"` - Number int `db:"number"` - Title string `db:"title"` - Slug string `db:"slug"` - Description string `db:"description"` - CreatedAt time.Time `db:"created_at"` - Search []byte `db:"search"` - User *dbUser `db:"user"` - HasVoted bool `db:"has_voted"` - VotesCount int `db:"votes_count"` - CommentsCount int `db:"comments_count"` - RecentVotes int `db:"recent_votes_count"` - RecentComments int `db:"recent_comments_count"` - Status int `db:"status"` - Response sql.NullString `db:"response"` - RespondedAt dbx.NullTime `db:"response_date"` - ResponseUser *dbUser `db:"response_user"` - OriginalNumber sql.NullInt64 `db:"original_number"` - OriginalTitle sql.NullString `db:"original_title"` - OriginalSlug sql.NullString `db:"original_slug"` - OriginalStatus sql.NullInt64 `db:"original_status"` - Tags []string `db:"tags"` -} - -func (i *dbPost) toModel(ctx context.Context) *entity.Post { - post := &entity.Post{ - ID: i.ID, - Number: i.Number, - Title: i.Title, - Slug: i.Slug, - Description: i.Description, - CreatedAt: i.CreatedAt, - HasVoted: i.HasVoted, - VotesCount: i.VotesCount, - CommentsCount: i.CommentsCount, - Status: enum.PostStatus(i.Status), - User: i.User.toModel(ctx), - Tags: i.Tags, - } - - if i.Response.Valid { - post.Response = &entity.PostResponse{ - Text: i.Response.String, - RespondedAt: i.RespondedAt.Time, - User: i.ResponseUser.toModel(ctx), - } - if post.Status == enum.PostDuplicate && i.OriginalNumber.Valid { - post.Response.Original = &entity.OriginalPost{ - Number: int(i.OriginalNumber.Int64), - Slug: i.OriginalSlug.String, - Title: i.OriginalTitle.String, - Status: enum.PostStatus(i.OriginalStatus.Int64), - } - } - } - return post -} - var ( - sqlSelectPostsWhere = ` WITH - agg_tags AS ( - SELECT - post_id, + sqlSelectPostsWhere = ` WITH + agg_tags AS ( + SELECT + post_id, ARRAY_REMOVE(ARRAY_AGG(tags.slug), NULL) as tags FROM post_tags INNER JOIN tags @@ -95,14 +36,14 @@ var ( AND tags.tenant_id = post_tags.tenant_id WHERE post_tags.tenant_id = $1 %s - GROUP BY post_id - ), + GROUP BY post_id + ), agg_comments AS ( - SELECT - post_id, - COUNT(CASE WHEN comments.created_at > CURRENT_DATE - INTERVAL '30 days' THEN 1 END) as recent, - COUNT(*) as all - FROM comments + SELECT + post_id, + COUNT(CASE WHEN comments.created_at > CURRENT_DATE - INTERVAL '30 days' AND comments.is_approved = true THEN 1 END) as recent, + COUNT(CASE WHEN comments.is_approved = true THEN 1 END) as all + FROM comments INNER JOIN posts ON posts.id = comments.post_id AND posts.tenant_id = comments.tenant_id @@ -111,11 +52,11 @@ var ( GROUP BY post_id ), agg_votes AS ( - SELECT - post_id, + SELECT + post_id, COUNT(CASE WHEN post_votes.created_at > CURRENT_DATE - INTERVAL '30 days' THEN 1 END) as recent, COUNT(*) as all - FROM post_votes + FROM post_votes INNER JOIN posts ON posts.id = post_votes.post_id AND posts.tenant_id = post_votes.tenant_id @@ -133,9 +74,9 @@ var ( COALESCE(agg_c.all, 0) as comments_count, COALESCE(agg_s.recent, 0) AS recent_votes_count, COALESCE(agg_c.recent, 0) AS recent_comments_count, - p.status, - u.id AS user_id, - u.name AS user_name, + p.status, + u.id AS user_id, + u.name AS user_name, u.email AS user_email, u.role AS user_role, u.status AS user_status, @@ -143,9 +84,9 @@ var ( u.avatar_bkey AS user_avatar_bkey, p.response, p.response_date, - r.id AS response_user_id, - r.name AS response_user_name, - r.email AS response_user_email, + r.id AS response_user_id, + r.name AS response_user_name, + r.email AS response_user_email, r.role AS response_user_role, r.status AS response_user_status, r.avatar_type AS response_user_avatar_type, @@ -155,7 +96,8 @@ var ( d.slug AS original_slug, d.status AS original_status, COALESCE(agg_t.tags, ARRAY[]::text[]) AS tags, - COALESCE(%s, false) AS has_voted + COALESCE(%s, false) AS has_voted, + p.is_approved FROM posts p INNER JOIN users u ON u.id = p.user_id @@ -170,7 +112,7 @@ var ( ON agg_c.post_id = p.id LEFT JOIN agg_votes agg_s ON agg_s.post_id = p.id - LEFT JOIN agg_tags agg_t + LEFT JOIN agg_tags agg_t ON agg_t.post_id = p.id WHERE p.status != ` + strconv.Itoa(int(enum.PostDeleted)) + ` AND %s` ) @@ -180,7 +122,7 @@ func postIsReferenced(ctx context.Context, q *query.PostIsReferenced) error { q.Result = false exists, err := trx.Exists(` - SELECT 1 FROM posts p + SELECT 1 FROM posts p INNER JOIN posts o ON o.tenant_id = p.tenant_id AND o.id = p.original_id @@ -207,8 +149,8 @@ func setPostResponse(ctx context.Context, c *cmd.SetPostResponse) error { } _, err := trx.Execute(` - UPDATE posts - SET response = $3, original_id = NULL, response_date = $4, response_user_id = $5, status = $6 + UPDATE posts + SET response = $3, original_id = NULL, response_date = $4, response_user_id = $5, status = $6 WHERE id = $1 and tenant_id = $2 `, c.Post.ID, tenant.ID, c.Text, respondedAt, user.ID, c.Status) if err != nil { @@ -232,22 +174,22 @@ func markPostAsDuplicate(ctx context.Context, c *cmd.MarkPostAsDuplicate) error respondedAt = c.Post.Response.RespondedAt } - var users []*dbUser + var users []*dbEntities.User err := trx.Select(&users, "SELECT user_id AS id FROM post_votes WHERE post_id = $1 AND tenant_id = $2", c.Post.ID, tenant.ID) if err != nil { return errors.Wrap(err, "failed to get votes of post with id '%d'", c.Post.ID) } for _, u := range users { - err := bus.Dispatch(ctx, &cmd.AddVote{Post: c.Original, User: u.toModel(ctx)}) + err := bus.Dispatch(ctx, &cmd.AddVote{Post: c.Original, User: u.ToModel(ctx)}) if err != nil { return err } } _, err = trx.Execute(` - UPDATE posts - SET response = '', original_id = $3, response_date = $4, response_user_id = $5, status = $6 + UPDATE posts + SET response = '', original_id = $3, response_date = $4, response_user_id = $5, status = $6 WHERE id = $1 and tenant_id = $2 `, c.Post.ID, tenant.ID, c.Original.ID, respondedAt, user.ID, enum.PostDuplicate) if err != nil { @@ -293,14 +235,15 @@ func countPostPerStatus(ctx context.Context, q *query.CountPostPerStatus) error func addNewPost(ctx context.Context, c *cmd.AddNewPost) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + isApproved := !tenant.IsModerationEnabled || !user.RequiresModeration() var id int // Detect language using lingua-go lang := detectPostLanguage(c.Title, c.Description) err := trx.Get(&id, - `INSERT INTO posts (title, slug, number, description, tenant_id, user_id, created_at, status, language) - VALUES ($1, $2, (SELECT COALESCE(MAX(number), 0) + 1 FROM posts p WHERE p.tenant_id = $4), $3, $4, $5, $6, 0, $7) - RETURNING id`, c.Title, slug.Make(c.Title), c.Description, tenant.ID, user.ID, time.Now(), lang) + `INSERT INTO posts (title, slug, number, description, tenant_id, user_id, created_at, status, is_approved, language) + VALUES ($1, $2, (SELECT COALESCE(MAX(number), 0) + 1 FROM posts p WHERE p.tenant_id = $4), $3, $4, $5, $6, 0, $7, $8) + RETURNING id`, c.Title, slug.Make(c.Title), c.Description, tenant.ID, user.ID, time.Now(), isApproved, lang) if err != nil { return errors.Wrap(err, "failed add new post") } @@ -323,10 +266,10 @@ func updatePost(ctx context.Context, c *cmd.UpdatePost) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { // Detect language using lingua-go lang := detectPostLanguage(c.Title, c.Description) - _, err := trx.Execute(`UPDATE posts SET title = $1, slug = $2, description = $3, language = $4 + _, err := trx.Execute(`UPDATE posts SET title = $1, slug = $2, description = $3, language = $4 WHERE id = $5 AND tenant_id = $6`, c.Title, slug.Make(c.Title), c.Description, lang, c.Post.ID, tenant.ID) - if err != nil { + if err != nil{ return errors.Wrap(err, "failed update post") } @@ -362,7 +305,7 @@ func detectPostLanguage(title, description string) string { func getPostByID(ctx context.Context, q *query.GetPostByID) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { - post, err := querySinglePost(ctx, trx, buildPostQuery(user, "p.tenant_id = $1 AND p.id = $2"), tenant.ID, q.PostID) + post, err := querySinglePost(ctx, trx, buildSinglePostQuery(user, "p.tenant_id = $1 AND p.id = $2"), tenant.ID, q.PostID) if err != nil { return errors.Wrap(err, "failed to get post with id '%d'", q.PostID) } @@ -373,7 +316,7 @@ func getPostByID(ctx context.Context, q *query.GetPostByID) error { func getPostBySlug(ctx context.Context, q *query.GetPostBySlug) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { - post, err := querySinglePost(ctx, trx, buildPostQuery(user, "p.tenant_id = $1 AND p.slug = $2"), tenant.ID, q.Slug) + post, err := querySinglePost(ctx, trx, buildSinglePostQuery(user, "p.tenant_id = $1 AND p.slug = $2"), tenant.ID, q.Slug) if err != nil { return errors.Wrap(err, "failed to get post with slug '%s'", q.Slug) } @@ -384,7 +327,7 @@ func getPostBySlug(ctx context.Context, q *query.GetPostBySlug) error { func getPostByNumber(ctx context.Context, q *query.GetPostByNumber) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { - post, err := querySinglePost(ctx, trx, buildPostQuery(user, "p.tenant_id = $1 AND p.number = $2"), tenant.ID, q.Number) + post, err := querySinglePost(ctx, trx, buildSinglePostQuery(user, "p.tenant_id = $1 AND p.number = $2"), tenant.ID, q.Number) if err != nil { return errors.Wrap(err, "failed to get post with number '%d'", q.Number) } @@ -419,12 +362,12 @@ func preprocessSearchQuery(query string) string { func findSimilarPosts(ctx context.Context, q *query.FindSimilarPosts) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { - innerQuery := buildPostQuery(user, "p.tenant_id = $1 AND p.status = ANY($2)") + innerQuery := buildPostQuery(user, "p.tenant_id = $1 AND p.status = ANY($2)", "") filteredQuery := preprocessSearchQuery(q.Query) var ( - posts []*dbPost + posts []*dbEntities.Post err error ) @@ -465,7 +408,7 @@ func findSimilarPosts(ctx context.Context, q *query.FindSimilarPosts) error { q.Result = make([]*entity.Post, len(posts)) for i, post := range posts { - q.Result[i] = post.toModel(ctx) + q.Result[i] = post.ToModel(ctx) } return nil }) @@ -473,7 +416,7 @@ func findSimilarPosts(ctx context.Context, q *query.FindSimilarPosts) error { func searchPosts(ctx context.Context, q *query.SearchPosts) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { - innerQuery := buildPostQuery(user, "p.tenant_id = $1 AND p.status = ANY($2)") + innerQuery := buildPostQuery(user, "p.tenant_id = $1 AND p.status = ANY($2)", q.ModerationFilter) if q.Tags == nil { q.Tags = []string{} @@ -490,7 +433,7 @@ func searchPosts(ctx context.Context, q *query.SearchPosts) error { } var ( - posts []*dbPost + posts []*dbEntities.Post err error ) if q.Query != "" { @@ -530,7 +473,7 @@ func searchPosts(ctx context.Context, q *query.SearchPosts) error { } sql := fmt.Sprintf(` - SELECT * FROM (%s) AS q + SELECT * FROM (%s) AS q WHERE 1 = 1 %s ORDER BY %s DESC LIMIT %s @@ -548,7 +491,7 @@ func searchPosts(ctx context.Context, q *query.SearchPosts) error { q.Result = make([]*entity.Post, len(posts)) for i, post := range posts { - q.Result[i] = post.toModel(ctx) + q.Result[i] = post.ToModel(ctx) } return nil }) @@ -566,16 +509,53 @@ func getAllPosts(ctx context.Context, q *query.GetAllPosts) error { } func querySinglePost(ctx context.Context, trx *dbx.Trx, query string, args ...any) (*entity.Post, error) { - post := dbPost{} + post := dbEntities.Post{} if err := trx.Get(&post, query, args...); err != nil { return nil, err } - return post.toModel(ctx), nil + return post.ToModel(ctx), nil +} + +func buildPostQuery(user *entity.User, filter string, moderationFilter string) string { + tagCondition := `AND tags.is_public = true` + if user != nil && user.IsCollaborator() { + tagCondition = `` + } + hasVotedSubQuery := "null" + if user != nil { + hasVotedSubQuery = fmt.Sprintf("(SELECT true FROM post_votes WHERE post_id = p.id AND user_id = %d)", user.ID) + } + + // Add approval filtering based on moderation filter and user permissions + approvalFilter := "" + + // If user is a collaborator and has specified a moderation filter, apply it + if user != nil && user.IsCollaborator() && moderationFilter != "" { + if moderationFilter == "pending" { + // Show only unapproved posts + approvalFilter = " AND p.is_approved = false" + } else if moderationFilter == "approved" { + // Show only approved posts + approvalFilter = " AND p.is_approved = true" + } + // If moderationFilter is neither "pending" nor "approved", show all posts (no filter) + } else if user != nil { + // Regular authenticated users can see approved posts + their own unapproved posts + approvalFilter = fmt.Sprintf(" AND (p.is_approved = true OR p.user_id = %d)", user.ID) + } else { + // Anonymous users can only see approved posts + approvalFilter = " AND p.is_approved = true" + } + + combinedFilter := filter + approvalFilter + return fmt.Sprintf(sqlSelectPostsWhere, tagCondition, hasVotedSubQuery, combinedFilter) } -func buildPostQuery(user *entity.User, filter string) string { +// buildSinglePostQuery is used for fetching individual posts (by ID, slug, or number) +// Collaborators can view any post for moderation purposes +func buildSinglePostQuery(user *entity.User, filter string) string { tagCondition := `AND tags.is_public = true` if user != nil && user.IsCollaborator() { tagCondition = `` @@ -584,5 +564,20 @@ func buildPostQuery(user *entity.User, filter string) string { if user != nil { hasVotedSubQuery = fmt.Sprintf("(SELECT true FROM post_votes WHERE post_id = p.id AND user_id = %d)", user.ID) } - return fmt.Sprintf(sqlSelectPostsWhere, tagCondition, hasVotedSubQuery, filter) + + // Approval filtering for single post views + approvalFilter := "" + if user != nil && user.IsCollaborator() { + // Collaborators can view any post (for moderation purposes) + approvalFilter = "" + } else if user != nil { + // Regular authenticated users can see approved posts + their own unapproved posts + approvalFilter = fmt.Sprintf(" AND (p.is_approved = true OR p.user_id = %d)", user.ID) + } else { + // Anonymous users can only see approved posts + approvalFilter = " AND p.is_approved = true" + } + + combinedFilter := filter + approvalFilter + return fmt.Sprintf(sqlSelectPostsWhere, tagCondition, hasVotedSubQuery, combinedFilter) } diff --git a/app/services/sqlstore/postgres/post_test.go b/app/services/sqlstore/postgres/post_test.go index ba5d7db40..1a776e695 100644 --- a/app/services/sqlstore/postgres/post_test.go +++ b/app/services/sqlstore/postgres/post_test.go @@ -24,10 +24,10 @@ func TestPostStorage_GetAll(t *testing.T) { now := time.Now() - _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('add twitter integration', 'add-twitter-integration', 1, 'Would be great to see it integrated with twitter', $1, 1, 1, 1, 'english')", now) + _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('add twitter integration', 'add-twitter-integration', 1, 'Would be great to see it integrated with twitter', $1, 1, 1, 1, true, 'english')", now) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('this is my post', 'this-is-my-post', 2, 'no description', $1, 1, 2, 2, 'english')", now) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('this is my post', 'this-is-my-post', 2, 'no description', $1, 1, 2, 2, true, 'english')", now) Expect(err).IsNil() allPosts := &query.GetAllPosts{} @@ -66,25 +66,25 @@ func TestPostStorage_SearchGermanPosts(t *testing.T) { now := time.Now() - _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Wann kommt der \"Wächter\" für das neue Stunden- und Vertretungsplanmodul?', 'wann-kommt-der-wachter-fur-das-neue-stunden-und-vertretungsplanmodul', 1, 'Description', $1, $2, $3, 0, 'german')", now, germanTenant.ID, germanJonSnow.ID) + _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Wann kommt der \"Wächter\" für das neue Stunden- und Vertretungsplanmodul?', 'wann-kommt-der-wachter-fur-das-neue-stunden-und-vertretungsplanmodul', 1, 'Description', $1, $2, $3, 0, true, 'german')", now, germanTenant.ID, germanJonSnow.ID) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Neuer Stunden/ Vertretungsplanwächter', 'neuer-stunden-vertretungsplanwachter', 2, 'no description', $1, $2, $3, 0, 'german')", now, germanTenant.ID, germanJonSnow.ID) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Neuer Stunden/ Vertretungsplanwächter', 'neuer-stunden-vertretungsplanwachter', 2, 'no description', $1, $2, $3, 0, true, 'german')", now, germanTenant.ID, germanJonSnow.ID) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Ungeklärte Vertretung wird zu Entfall', 'ungeklarte-vertretung-wird-zu-entfall', 3, 'some description', $1, $2, $3, 0, 'german')", now, germanTenant.ID, germanJonSnow.ID) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Ungeklärte Vertretung wird zu Entfall', 'ungeklarte-vertretung-wird-zu-entfall', 3, 'some description', $1, $2, $3, 0, true, 'german')", now, germanTenant.ID, germanJonSnow.ID) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Vertretungsplan drucken', 'vertretungsplan-drucken', 4, 'another description', $1, $2, $3, 0, 'german')", now, germanTenant.ID, germanJonSnow.ID) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Vertretungsplan drucken', 'vertretungsplan-drucken', 4, 'another description', $1, $2, $3, 0, true, 'german')", now, germanTenant.ID, germanJonSnow.ID) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Vertretungsplanung Wochenansicht', 'vertretungsplanung-wochenansicht', 5, 'description here', $1, $2, $3, 0, 'german')", now, germanTenant.ID, germanJonSnow.ID) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Vertretungsplanung Wochenansicht', 'vertretungsplanung-wochenansicht', 5, 'description here', $1, $2, $3, 0, true, 'german')", now, germanTenant.ID, germanJonSnow.ID) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('VertretungsBoard', 'vertretungsboard', 6, 'board description', $1, $2, $3, 0, 'german')", now, germanTenant.ID, germanJonSnow.ID) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('VertretungsBoard', 'vertretungsboard', 6, 'board description', $1, $2, $3, 0, true, 'german')", now, germanTenant.ID, germanJonSnow.ID) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Abwesenheiten Vertretungsplan', 'abwesenheiten-vertretungsplan', 7, 'final description', $1, $2, $3, 0, 'german')", now, germanTenant.ID, germanJonSnow.ID) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Abwesenheiten Vertretungsplan', 'abwesenheiten-vertretungsplan', 7, 'final description', $1, $2, $3, 0, true, 'german')", now, germanTenant.ID, germanJonSnow.ID) Expect(err).IsNil() // Search for "Vertretung" - with German stemming, should match all posts containing Vertretung* variants @@ -104,16 +104,16 @@ func TestPostStorage_SearchEnglishPosts_SingleWord(t *testing.T) { now := time.Now() // Create posts with various forms of "integration" - _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Add Twitter Integration', 'add-twitter-integration', 1, 'Would be great to integrate with Twitter', $1, 1, 1, 0, 'english')", now) + _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Add Twitter Integration', 'add-twitter-integration', 1, 'Would be great to integrate with Twitter', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('GitHub Integration Needed', 'github-integration-needed', 2, 'Please add GitHub integration', $1, 1, 1, 0, 'english')", now) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('GitHub Integration Needed', 'github-integration-needed', 2, 'Please add GitHub integration', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Integrate with Slack', 'integrate-with-slack', 3, 'Slack integration would be useful', $1, 1, 1, 0, 'english')", now) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Integrate with Slack', 'integrate-with-slack', 3, 'Slack integration would be useful', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Email Notifications', 'email-notifications', 4, 'Add email notification support', $1, 1, 1, 0, 'english')", now) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Email Notifications', 'email-notifications', 4, 'Add email notification support', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() // Search for "integration" - should match posts 1, 2, and 3 (with stemming, "integrate" = "integration") @@ -138,16 +138,16 @@ func TestPostStorage_SearchEnglishPosts_MultiWord(t *testing.T) { now := time.Now() - _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Dark Mode Support', 'dark-mode-support', 1, 'Add dark mode to the application', $1, 1, 1, 0, 'english')", now) + _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Dark Mode Support', 'dark-mode-support', 1, 'Add dark mode to the application', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Light Theme Customization', 'light-theme-customization', 2, 'Customize the light theme colors', $1, 1, 1, 0, 'english')", now) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Light Theme Customization', 'light-theme-customization', 2, 'Customize the light theme colors', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Dark Theme Colors', 'dark-theme-colors', 3, 'Change dark theme color scheme', $1, 1, 1, 0, 'english')", now) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Dark Theme Colors', 'dark-theme-colors', 3, 'Change dark theme color scheme', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('User Profile', 'user-profile', 4, 'Improve user profile page', $1, 1, 1, 0, 'english')", now) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('User Profile', 'user-profile', 4, 'Improve user profile page', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() searchDarkTheme := &query.SearchPosts{Query: "dark theme"} @@ -163,13 +163,13 @@ func TestPostStorage_SearchEnglishPosts_PartialMatch(t *testing.T) { now := time.Now() - _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Authentication System', 'authentication-system', 1, 'Improve authentication', $1, 1, 1, 0, 'english')", now) + _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Authentication System', 'authentication-system', 1, 'Improve authentication', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Authorization Rules', 'authorization-rules', 2, 'Add better authorization', $1, 1, 1, 0, 'english')", now) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Authorization Rules', 'authorization-rules', 2, 'Add better authorization', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('OAuth Support', 'oauth-support', 3, 'Support OAuth providers', $1, 1, 1, 0, 'english')", now) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('OAuth Support', 'oauth-support', 3, 'Support OAuth providers', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() // Search for "auth" - should match posts 1 and 2 (prefix matching) @@ -192,10 +192,10 @@ func TestPostStorage_SearchEnglishPosts_CaseInsensitive(t *testing.T) { now := time.Now() - _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('API Documentation', 'api-documentation', 1, 'Improve API docs', $1, 1, 1, 0, 'english')", now) + _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('API Documentation', 'api-documentation', 1, 'Improve API docs', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Rest API Endpoints', 'rest-api-endpoints', 2, 'Add more REST API endpoints', $1, 1, 1, 0, 'english')", now) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Rest API Endpoints', 'rest-api-endpoints', 2, 'Add more REST API endpoints', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() // Search with different cases - all should return same results @@ -217,13 +217,13 @@ func TestPostStorage_SearchEnglishPosts_DescriptionMatch(t *testing.T) { now := time.Now() - _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Feature Request', 'feature-request-1', 1, 'Add support for exporting data to CSV format', $1, 1, 1, 0, 'english')", now) + _, err := trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Feature Request', 'feature-request-1', 1, 'Add support for exporting data to CSV format', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Another Feature', 'feature-request-2', 2, 'Improve the dashboard layout', $1, 1, 1, 0, 'english')", now) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Another Feature', 'feature-request-2', 2, 'Improve the dashboard layout', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() - _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, language) VALUES ('Export Functionality', 'export-functionality', 3, 'Export reports in PDF format', $1, 1, 1, 0, 'english')", now) + _, err = trx.Execute("INSERT INTO posts (title, slug, number, description, created_at, tenant_id, user_id, status, is_approved, language) VALUES ('Export Functionality', 'export-functionality', 3, 'Export reports in PDF format', $1, 1, 1, 0, true, 'english')", now) Expect(err).IsNil() // Search for "export" - should match posts 1 and 3 (one in description, one in title) diff --git a/app/services/sqlstore/postgres/postgres.go b/app/services/sqlstore/postgres/postgres.go index f2247702a..ea34d695a 100644 --- a/app/services/sqlstore/postgres/postgres.go +++ b/app/services/sqlstore/postgres/postgres.go @@ -4,6 +4,7 @@ import ( "context" "github.com/getfider/fider/app" + "github.com/getfider/fider/app/services" "github.com/getfider/fider/app/models/entity" "github.com/getfider/fider/app/pkg/bus" @@ -85,6 +86,7 @@ func (s Service) Init() { bus.AddHandler(countUsers) bus.AddHandler(blockUser) bus.AddHandler(unblockUser) + bus.AddHandler(untrustUser) bus.AddHandler(regenerateAPIKey) bus.AddHandler(userSubscribedTo) bus.AddHandler(deleteCurrentUser) @@ -101,6 +103,7 @@ func (s Service) Init() { bus.AddHandler(getUserByProvider) bus.AddHandler(getAllUsers) bus.AddHandler(getAllUsersNames) + bus.AddHandler(searchUsers) bus.AddHandler(createTenant) bus.AddHandler(getFirstTenant) @@ -134,16 +137,30 @@ func (s Service) Init() { bus.AddHandler(deleteWebhook) bus.AddHandler(markWebhookAsFailed) - bus.AddHandler(getBillingState) bus.AddHandler(activateBillingSubscription) bus.AddHandler(cancelBillingSubscription) - bus.AddHandler(lockExpiredTenants) - bus.AddHandler(getTrialingTenantContacts) + bus.AddHandler(getStripeBillingState) + bus.AddHandler(activateStripeSubscription) + bus.AddHandler(cancelStripeSubscription) bus.AddHandler(setSystemSettings) bus.AddHandler(getSystemSettings) bus.AddHandler(AddMentionNotification) bus.AddHandler(getMentionsNotifications) + + // Only register moderation handlers if commercial service is not available + // Check if commercial features are enabled via license service + if !services.IsCommercialFeatureEnabled(services.FeatureContentModeration) { + bus.AddHandler(approvePost) + bus.AddHandler(declinePost) + bus.AddHandler(approveComment) + bus.AddHandler(declineComment) + bus.AddHandler(bulkApproveItems) + bus.AddHandler(bulkDeclineItems) + bus.AddHandler(getModerationItems) + bus.AddHandler(getModerationCount) + bus.AddHandler(trustUser) + } } type SqlHandler func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error diff --git a/app/services/sqlstore/postgres/tag.go b/app/services/sqlstore/postgres/tag.go index aaab85c7b..0d57cf174 100644 --- a/app/services/sqlstore/postgres/tag.go +++ b/app/services/sqlstore/postgres/tag.go @@ -10,27 +10,10 @@ import ( "github.com/getfider/fider/app/models/query" "github.com/getfider/fider/app/pkg/dbx" "github.com/getfider/fider/app/pkg/errors" + "github.com/getfider/fider/app/services/sqlstore/dbEntities" "github.com/gosimple/slug" ) -type dbTag struct { - ID int `db:"id"` - Name string `db:"name"` - Slug string `db:"slug"` - Color string `db:"color"` - IsPublic bool `db:"is_public"` -} - -func (t *dbTag) toModel() *entity.Tag { - return &entity.Tag{ - ID: t.ID, - Name: t.Name, - Slug: t.Slug, - Color: t.Color, - IsPublic: t.IsPublic, - } -} - func getTagBySlug(ctx context.Context, q *query.GetTagBySlug) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { tag, err := queryTagBySlug(trx, tenant, q.Slug) @@ -176,18 +159,18 @@ func unassignTag(ctx context.Context, c *cmd.UnassignTag) error { } func queryTagBySlug(trx *dbx.Trx, tenant *entity.Tenant, slug string) (*entity.Tag, error) { - tag := dbTag{} + tag := dbEntities.Tag{} err := trx.Get(&tag, "SELECT id, name, slug, color, is_public FROM tags WHERE tenant_id = $1 AND slug = $2", tenant.ID, slug) if err != nil { return nil, errors.Wrap(err, "failed to get tag with slug '%s'", slug) } - return tag.toModel(), nil + return tag.ToModel(), nil } func queryTags(trx *dbx.Trx, query string, args ...any) ([]*entity.Tag, error) { - tags := []*dbTag{} + tags := []*dbEntities.Tag{} err := trx.Select(&tags, query, args...) if err != nil { return nil, err @@ -195,7 +178,7 @@ func queryTags(trx *dbx.Trx, query string, args ...any) ([]*entity.Tag, error) { var result = make([]*entity.Tag, len(tags)) for i, tag := range tags { - result[i] = tag.toModel() + result[i] = tag.ToModel() } return result, nil } diff --git a/app/services/sqlstore/postgres/tenant.go b/app/services/sqlstore/postgres/tenant.go index 5473ad2c4..8f5fb358c 100644 --- a/app/services/sqlstore/postgres/tenant.go +++ b/app/services/sqlstore/postgres/tenant.go @@ -5,6 +5,7 @@ import ( "time" "github.com/getfider/fider/app/pkg/bus" + "github.com/getfider/fider/app/services/sqlstore/dbEntities" "github.com/getfider/fider/app/models/cmd" "github.com/getfider/fider/app/models/entity" @@ -16,84 +17,6 @@ import ( "github.com/getfider/fider/app/pkg/errors" ) -type dbTenant struct { - ID int `db:"id"` - Name string `db:"name"` - Subdomain string `db:"subdomain"` - CNAME string `db:"cname"` - Invitation string `db:"invitation"` - WelcomeMessage string `db:"welcome_message"` - Status int `db:"status"` - Locale string `db:"locale"` - IsPrivate bool `db:"is_private"` - LogoBlobKey string `db:"logo_bkey"` - CustomCSS string `db:"custom_css"` - AllowedSchemes string `db:"allowed_schemes"` - IsEmailAuthAllowed bool `db:"is_email_auth_allowed"` - IsFeedEnabled bool `db:"is_feed_enabled"` - PreventIndexing bool `db:"prevent_indexing"` -} - -func (t *dbTenant) toModel() *entity.Tenant { - if t == nil { - return nil - } - - tenant := &entity.Tenant{ - ID: t.ID, - Name: t.Name, - Subdomain: t.Subdomain, - CNAME: t.CNAME, - Invitation: t.Invitation, - WelcomeMessage: t.WelcomeMessage, - Status: enum.TenantStatus(t.Status), - Locale: t.Locale, - IsPrivate: t.IsPrivate, - LogoBlobKey: t.LogoBlobKey, - CustomCSS: t.CustomCSS, - AllowedSchemes: t.AllowedSchemes, - IsEmailAuthAllowed: t.IsEmailAuthAllowed, - IsFeedEnabled: t.IsFeedEnabled, - PreventIndexing: t.PreventIndexing, - } - - return tenant -} - -type dbEmailVerification struct { - ID int `db:"id"` - Name string `db:"name"` - Email string `db:"email"` - Key string `db:"key"` - Kind enum.EmailVerificationKind `db:"kind"` - UserID dbx.NullInt `db:"user_id"` - CreatedAt time.Time `db:"created_at"` - ExpiresAt time.Time `db:"expires_at"` - VerifiedAt dbx.NullTime `db:"verified_at"` -} - -func (t *dbEmailVerification) toModel() *entity.EmailVerification { - model := &entity.EmailVerification{ - Name: t.Name, - Email: t.Email, - Key: t.Key, - Kind: t.Kind, - CreatedAt: t.CreatedAt, - ExpiresAt: t.ExpiresAt, - VerifiedAt: nil, - } - - if t.VerifiedAt.Valid { - model.VerifiedAt = &t.VerifiedAt.Time - } - - if t.UserID.Valid { - model.UserID = int(t.UserID.Int64) - } - - return model -} - func isCNAMEAvailable(ctx context.Context, q *query.IsCNAMEAvailable) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { tenantID := 0 @@ -133,6 +56,10 @@ func updateTenantPrivacySettings(ctx context.Context, c *cmd.UpdateTenantPrivacy if err != nil { return errors.Wrap(err, "failed update tenant feed setting") } + _, err = trx.Execute("UPDATE tenants SET is_moderation_enabled = $1 WHERE id = $2", c.IsModerationEnabled, tenant.ID) + if err != nil { + return errors.Wrap(err, "failed update tenant moderation setting") + } return nil }) } @@ -200,7 +127,7 @@ func activateTenant(ctx context.Context, c *cmd.ActivateTenant) error { func getVerificationByKey(ctx context.Context, q *query.GetVerificationByKey) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { - verification := dbEmailVerification{} + verification := dbEntities.EmailVerification{} query := "SELECT id, email, name, key, created_at, verified_at, expires_at, kind, user_id FROM email_verifications WHERE key = $1 AND kind = $2 LIMIT 1" err := trx.Get(&verification, query, q.Key, q.Kind) @@ -208,14 +135,14 @@ func getVerificationByKey(ctx context.Context, q *query.GetVerificationByKey) er return errors.Wrap(err, "failed to get email verification by its key") } - q.Result = verification.toModel() + q.Result = verification.ToModel() return nil }) } func getVerificationByEmailAndCode(ctx context.Context, q *query.GetVerificationByEmailAndCode) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { - verification := dbEmailVerification{} + verification := dbEntities.EmailVerification{} query := "SELECT id, email, name, key, created_at, verified_at, expires_at, kind, user_id FROM email_verifications WHERE tenant_id = $1 AND email = $2 AND key = $3 AND kind = $4 LIMIT 1" err := trx.Get(&verification, query, tenant.ID, q.Email, q.Code, q.Kind) @@ -223,7 +150,7 @@ func getVerificationByEmailAndCode(ctx context.Context, q *query.GetVerification return errors.Wrap(err, "failed to get email verification by email and code") } - q.Result = verification.toModel() + q.Result = verification.ToModel() return nil }) } @@ -261,23 +188,13 @@ func createTenant(ctx context.Context, c *cmd.CreateTenant) error { var id int err := trx.Get(&id, - `INSERT INTO tenants (name, subdomain, created_at, cname, invitation, welcome_message, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, prevent_indexing) - VALUES ($1, $2, $3, '', '', '', $4, false, '', '', $5, true, true, true) + `INSERT INTO tenants (name, subdomain, created_at, cname, invitation, welcome_message, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, prevent_indexing, is_moderation_enabled) + VALUES ($1, $2, $3, '', '', '', $4, false, '', '', $5, true, true, true, false) RETURNING id`, c.Name, c.Subdomain, now, c.Status, env.Config.Locale) if err != nil { return err } - if env.IsBillingEnabled() { - trialEndsAt := time.Now().AddDate(0, 0, 15) // 15 days - _, err := trx.Execute( - `INSERT INTO tenants_billing (tenant_id, trial_ends_at, status, paddle_subscription_id, paddle_plan_id) - VALUES ($1, $2, $3, '', '')`, id, trialEndsAt, enum.BillingTrial) - if err != nil { - return err - } - } - byDomain := &query.GetTenantByDomain{Domain: c.Subdomain} err = bus.Dispatch(ctx, byDomain) c.Result = byDomain.Result @@ -287,29 +204,28 @@ func createTenant(ctx context.Context, c *cmd.CreateTenant) error { func getFirstTenant(ctx context.Context, q *query.GetFirstTenant) error { return using(ctx, func(trx *dbx.Trx, _ *entity.Tenant, _ *entity.User) error { - tenant := dbTenant{} + tenant := dbEntities.Tenant{} err := trx.Get(&tenant, ` - SELECT id, name, subdomain, cname, invitation, locale, welcome_message, status, is_private, logo_bkey, custom_css, allowed_schemes, is_email_auth_allowed, is_feed_enabled, prevent_indexing + SELECT id, name, subdomain, cname, invitation, locale, welcome_message, status, is_private, logo_bkey, custom_css, allowed_schemes, is_email_auth_allowed, is_feed_enabled, is_moderation_enabled, prevent_indexing, is_pro FROM tenants ORDER BY id LIMIT 1 `) - if err != nil { return errors.Wrap(err, "failed to get first tenant") } - q.Result = tenant.toModel() + q.Result = tenant.ToModel() return nil }) } func getTenantByDomain(ctx context.Context, q *query.GetTenantByDomain) error { return using(ctx, func(trx *dbx.Trx, _ *entity.Tenant, _ *entity.User) error { - tenant := dbTenant{} + tenant := dbEntities.Tenant{} err := trx.Get(&tenant, ` - SELECT id, name, subdomain, cname, invitation, locale, welcome_message, status, is_private, logo_bkey, custom_css, allowed_schemes, is_email_auth_allowed, is_feed_enabled, prevent_indexing + SELECT id, name, subdomain, cname, invitation, locale, welcome_message, status, is_private, logo_bkey, custom_css, allowed_schemes, is_email_auth_allowed, is_feed_enabled, is_moderation_enabled, prevent_indexing, is_pro FROM tenants t WHERE subdomain = $1 OR subdomain = $2 OR cname = $3 ORDER BY cname DESC @@ -318,14 +234,14 @@ func getTenantByDomain(ctx context.Context, q *query.GetTenantByDomain) error { return errors.Wrap(err, "failed to get tenant with domain '%s'", q.Domain) } - q.Result = tenant.toModel() + q.Result = tenant.ToModel() return nil }) } func getPendingSignUpVerification(ctx context.Context, q *query.GetPendingSignUpVerification) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { - verification := dbEmailVerification{} + verification := dbEntities.EmailVerification{} query := `SELECT id, email, name, key, created_at, verified_at, expires_at, kind, user_id FROM email_verifications @@ -337,7 +253,7 @@ func getPendingSignUpVerification(ctx context.Context, q *query.GetPendingSignUp return errors.Wrap(err, "failed to get pending signup verification for tenant '%d'", tenant.ID) } - q.Result = verification.toModel() + q.Result = verification.ToModel() return nil }) } diff --git a/app/services/sqlstore/postgres/tenant_test.go b/app/services/sqlstore/postgres/tenant_test.go index b1f887b05..4011b318c 100644 --- a/app/services/sqlstore/postgres/tenant_test.go +++ b/app/services/sqlstore/postgres/tenant_test.go @@ -222,7 +222,7 @@ func TestTenantStorage_AdvancedSettings(t *testing.T) { defer TeardownDatabaseTest() err := bus.Dispatch(demoTenantCtx, &cmd.UpdateTenantAdvancedSettings{ - CustomCSS: ".primary { color: red; }", + CustomCSS: ".primary { color: red; }", AllowedSchemes: "^monero:[48]\n^bitcoin:(1|3|bc1)", }) Expect(err).IsNil() diff --git a/app/services/sqlstore/postgres/user.go b/app/services/sqlstore/postgres/user.go index b3930d9da..d765927a5 100644 --- a/app/services/sqlstore/postgres/user.go +++ b/app/services/sqlstore/postgres/user.go @@ -2,7 +2,6 @@ package postgres import ( "context" - "database/sql" "fmt" "strings" "time" @@ -15,64 +14,10 @@ import ( "github.com/getfider/fider/app/models/query" "github.com/getfider/fider/app/pkg/dbx" "github.com/getfider/fider/app/pkg/errors" + "github.com/getfider/fider/app/services/sqlstore/dbEntities" + "github.com/lib/pq" ) -type dbUser struct { - ID sql.NullInt64 `db:"id"` - Name sql.NullString `db:"name"` - Email sql.NullString `db:"email"` - Tenant *dbTenant `db:"tenant"` - Role sql.NullInt64 `db:"role"` - Status sql.NullInt64 `db:"status"` - AvatarType sql.NullInt64 `db:"avatar_type"` - AvatarBlobKey sql.NullString `db:"avatar_bkey"` - Providers []*dbUserProvider -} - -type dbUserProvider struct { - Name sql.NullString `db:"provider"` - UID sql.NullString `db:"provider_uid"` -} - -func (u *dbUser) toModel(ctx context.Context) *entity.User { - if u == nil { - return nil - } - - avatarURL := "" - avatarType := enum.AvatarType(u.AvatarType.Int64) - if u.AvatarType.Valid { - avatarURL = buildAvatarURL(ctx, avatarType, int(u.ID.Int64), u.Name.String, u.AvatarBlobKey.String) - } - - user := &entity.User{ - ID: int(u.ID.Int64), - Name: u.Name.String, - Email: u.Email.String, - Tenant: u.Tenant.toModel(), - Role: enum.Role(u.Role.Int64), - Providers: make([]*entity.UserProvider, len(u.Providers)), - Status: enum.UserStatus(u.Status.Int64), - AvatarType: avatarType, - AvatarBlobKey: u.AvatarBlobKey.String, - AvatarURL: avatarURL, - } - - for i, p := range u.Providers { - user.Providers[i] = &entity.UserProvider{ - Name: p.Name.String, - UID: p.UID.String, - } - } - - return user -} - -type dbUserSetting struct { - Key string `db:"key"` - Value string `db:"value"` -} - func countUsers(ctx context.Context, q *query.CountUsers) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { var count int @@ -109,6 +54,18 @@ func unblockUser(ctx context.Context, c *cmd.UnblockUser) error { }) } +func untrustUser(ctx context.Context, c *cmd.UntrustUser) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + if _, err := trx.Execute( + "UPDATE users SET is_trusted = false WHERE id = $1 AND tenant_id = $2", + c.UserID, tenant.ID, + ); err != nil { + return errors.Wrap(err, "failed to untrust user") + } + return nil + }) +} + func deleteCurrentUser(ctx context.Context, c *cmd.DeleteCurrentUser) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { if _, err := trx.Execute( @@ -253,7 +210,7 @@ func getCurrentUserSettings(ctx context.Context, q *query.GetCurrentUserSettings return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { q.Result = make(map[string]string) - var settings []*dbUserSetting + var settings []*dbEntities.UserSetting err := trx.Select(&settings, "SELECT key, value FROM user_settings WHERE user_id = $1 AND tenant_id = $2", user.ID, tenant.ID) if err != nil { return errors.Wrap(err, "failed to get user settings") @@ -349,13 +306,13 @@ func getUserByProvider(ctx context.Context, q *query.GetUserByProvider) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { var userID int if err := trx.Scalar(&userID, ` - SELECT user_id - FROM user_providers up - INNER JOIN users u - ON u.id = up.user_id - AND u.tenant_id = up.tenant_id - WHERE up.provider = $1 - AND up.provider_uid = $2 + SELECT user_id + FROM user_providers up + INNER JOIN users u + ON u.id = up.user_id + AND u.tenant_id = up.tenant_id + WHERE up.provider = $1 + AND up.provider_uid = $2 AND u.tenant_id = $3`, q.Provider, q.UID, tenant.ID); err != nil { return errors.Wrap(err, "failed to get user by provider '%s' and uid '%s'", q.Provider, q.UID) } @@ -369,11 +326,11 @@ func getUserByProvider(ctx context.Context, q *query.GetUserByProvider) error { func getAllUsers(ctx context.Context, q *query.GetAllUsers) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { - var users []*dbUser + var users []*dbEntities.User err := trx.Select(&users, ` SELECT id, name, email, tenant_id, role, status, avatar_type, avatar_bkey - FROM users - WHERE tenant_id = $1 + FROM users + WHERE tenant_id = $1 AND status != $2 ORDER BY id`, tenant.ID, enum.UserDeleted) if err != nil { @@ -382,7 +339,7 @@ func getAllUsers(ctx context.Context, q *query.GetAllUsers) error { q.Result = make([]*entity.User, len(users)) for i, user := range users { - q.Result[i] = user.toModel(ctx) + q.Result[i] = user.ToModel(ctx) } return nil }) @@ -390,11 +347,11 @@ func getAllUsers(ctx context.Context, q *query.GetAllUsers) error { func getAllUsersNames(ctx context.Context, q *query.GetAllUsersNames) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { - var users []*dbUser + var users []*dbEntities.User err := trx.Select(&users, ` SELECT name - FROM users - WHERE tenant_id = $1 + FROM users + WHERE tenant_id = $1 AND status = $2 ORDER BY id`, tenant.ID, enum.UserActive) if err != nil { @@ -412,8 +369,8 @@ func getAllUsersNames(ctx context.Context, q *query.GetAllUsersNames) error { } func queryUser(ctx context.Context, trx *dbx.Trx, filter string, args ...any) (*entity.User, error) { - user := dbUser{} - sql := fmt.Sprintf("SELECT id, name, email, tenant_id, role, status, avatar_type, avatar_bkey FROM users WHERE status != %d AND ", enum.UserDeleted) + user := dbEntities.User{} + sql := fmt.Sprintf("SELECT id, name, email, tenant_id, role, status, avatar_type, avatar_bkey, is_trusted FROM users WHERE status != %d AND ", enum.UserDeleted) err := trx.Get(&user, sql+filter, args...) if err != nil { return nil, err @@ -424,5 +381,108 @@ func queryUser(ctx context.Context, trx *dbx.Trx, filter string, args ...any) (* return nil, err } - return user.toModel(ctx), nil + return user.ToModel(ctx), nil +} + +func searchUsers(ctx context.Context, q *query.SearchUsers) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + if q.Roles == nil { + q.Roles = []string{} + } + if q.Limit <= 0 { + q.Limit = 10 + } + if q.Page <= 0 { + q.Page = 1 + } + + baseQuery := ` + SELECT id, name, email, tenant_id, role, status, avatar_type, avatar_bkey, is_trusted + FROM users + WHERE tenant_id = $1 AND status != $2 + ` + args := []interface{}{tenant.ID, enum.UserDeleted} + argIndex := 3 + + // Add search filter + if q.Query != "" { + baseQuery += fmt.Sprintf(" AND (name ILIKE $%d OR email ILIKE $%d)", argIndex, argIndex+1) + searchTerm := "%" + q.Query + "%" + args = append(args, searchTerm, searchTerm) + argIndex += 2 + } + + // Add role filter + if len(q.Roles) > 0 { + roleValues := make([]interface{}, len(q.Roles)) + for i, roleStr := range q.Roles { + switch roleStr { + case "administrator": + roleValues[i] = enum.RoleAdministrator + case "collaborator": + roleValues[i] = enum.RoleCollaborator + case "visitor": + roleValues[i] = enum.RoleVisitor + default: + roleValues[i] = enum.RoleVisitor + } + } + baseQuery += fmt.Sprintf(" AND role = ANY($%d)", argIndex) + args = append(args, pq.Array(roleValues)) + } + + baseQuery += " ORDER BY role desc, name" + + // First, get the total count for pagination + countQuery := `SELECT COUNT(*) FROM users WHERE tenant_id = $1 AND status != $2` + countArgs := []interface{}{tenant.ID, enum.UserDeleted} + countArgIndex := 3 + + // Add the same filters for counting + if q.Query != "" { + countQuery += fmt.Sprintf(" AND (name ILIKE $%d OR email ILIKE $%d)", countArgIndex, countArgIndex+1) + searchTerm := "%" + q.Query + "%" + countArgs = append(countArgs, searchTerm, searchTerm) + countArgIndex += 2 + } + + if len(q.Roles) > 0 { + roleValues := make([]interface{}, len(q.Roles)) + for i, roleStr := range q.Roles { + switch roleStr { + case "administrator": + roleValues[i] = enum.RoleAdministrator + case "collaborator": + roleValues[i] = enum.RoleCollaborator + case "visitor": + roleValues[i] = enum.RoleVisitor + default: + roleValues[i] = enum.RoleVisitor + } + } + countQuery += fmt.Sprintf(" AND role = ANY($%d)", countArgIndex) + countArgs = append(countArgs, pq.Array(roleValues)) + } + + err := trx.Get(&q.TotalCount, countQuery, countArgs...) + if err != nil { + return errors.Wrap(err, "failed to count users") + } + + // Add pagination to main query + offset := (q.Page - 1) * q.Limit + baseQuery += fmt.Sprintf(" LIMIT %d OFFSET %d", q.Limit, offset) + + var users []*dbEntities.User + err = trx.Select(&users, baseQuery, args...) + if err != nil { + return errors.Wrap(err, "failed to search users") + } + + q.Result = make([]*entity.User, len(users)) + for i, user := range users { + q.Result[i] = user.ToModel(ctx) + } + return nil + }) } diff --git a/app/services/sqlstore/postgres/user_test.go b/app/services/sqlstore/postgres/user_test.go index 2fa4643d6..bc42a4bb1 100644 --- a/app/services/sqlstore/postgres/user_test.go +++ b/app/services/sqlstore/postgres/user_test.go @@ -158,8 +158,8 @@ func TestUserStorage_Register_MultipleProviders(t *testing.T) { var tenantID int err := trx.Get(&tenantID, ` - INSERT INTO tenants (name, subdomain, created_at, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled) - VALUES ('My Domain Inc.','mydomain', now(), 1, false, '', '', 'en', true, true) + INSERT INTO tenants (name, subdomain, created_at, status, is_private, custom_css, logo_bkey, locale, is_email_auth_allowed, is_feed_enabled, is_moderation_enabled) + VALUES ('My Domain Inc.','mydomain', now(), 1, false, '', '', 'en', true, true, false) RETURNING id `) Expect(err).IsNil() diff --git a/app/services/sqlstore/postgres/vote.go b/app/services/sqlstore/postgres/vote.go index 2faa698a6..05baafff4 100644 --- a/app/services/sqlstore/postgres/vote.go +++ b/app/services/sqlstore/postgres/vote.go @@ -7,36 +7,12 @@ import ( "github.com/getfider/fider/app/models/cmd" "github.com/getfider/fider/app/models/entity" - "github.com/getfider/fider/app/models/enum" "github.com/getfider/fider/app/models/query" "github.com/getfider/fider/app/pkg/dbx" "github.com/getfider/fider/app/pkg/errors" + "github.com/getfider/fider/app/services/sqlstore/dbEntities" ) -type dbVote struct { - User *struct { - ID int `db:"id"` - Name string `db:"name"` - Email string `db:"email"` - AvatarType int64 `db:"avatar_type"` - AvatarBlobKey string `db:"avatar_bkey"` - } `db:"user"` - CreatedAt time.Time `db:"created_at"` -} - -func (v *dbVote) toModel(ctx context.Context) *entity.Vote { - vote := &entity.Vote{ - CreatedAt: v.CreatedAt, - User: &entity.VoteUser{ - ID: v.User.ID, - Name: v.User.Name, - Email: v.User.Email, - AvatarURL: buildAvatarURL(ctx, enum.AvatarType(v.User.AvatarType), v.User.ID, v.User.Name, v.User.AvatarBlobKey), - }, - } - return vote -} - func addVote(ctx context.Context, c *cmd.AddVote) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { if !c.Post.CanBeVoted() { @@ -84,7 +60,7 @@ func listPostVotes(ctx context.Context, q *query.ListPostVotes) error { emailColumn = "u.email" } - votes := []*dbVote{} + votes := []*dbEntities.Vote{} err := trx.Select(&votes, ` SELECT pv.created_at, @@ -107,7 +83,7 @@ func listPostVotes(ctx context.Context, q *query.ListPostVotes) error { q.Result = make([]*entity.Vote, len(votes)) for i, vote := range votes { - q.Result[i] = vote.toModel(ctx) + q.Result[i] = vote.ToModel(ctx) } return nil diff --git a/app/services/userlist/userlist.go b/app/services/userlist/userlist.go index 8b0020aad..ae737f3cd 100644 --- a/app/services/userlist/userlist.go +++ b/app/services/userlist/userlist.go @@ -153,9 +153,9 @@ func updateUserListCompany(ctx context.Context, c *cmd.UserListUpdateCompany) er Name: c.Name, } - if c.BillingStatus > 0 { + if c.Plan > 0 { company.Properties = map[string]interface{}{ - "billing_status": c.BillingStatus.String(), + "plan": c.Plan.String(), } } @@ -174,8 +174,8 @@ func createUserListCompany(ctx context.Context, c *cmd.UserListCreateCompany) er Name: c.Name, SignedUpAt: c.SignedUpAt, Properties: map[string]interface{}{ - "billing_status": c.BillingStatus, - "subdomain": c.Subdomain, + "plan": c.Plan.String(), + "subdomain": c.Subdomain, }, Users: []UserListUser{ { diff --git a/app/services/userlist/userlist_test.go b/app/services/userlist/userlist_test.go index d775c9f9c..e02a674bb 100644 --- a/app/services/userlist/userlist_test.go +++ b/app/services/userlist/userlist_test.go @@ -36,14 +36,14 @@ func TestCreateTenant_Success(t *testing.T) { reset() createCompanyCmd := &cmd.UserListCreateCompany{ - Name: "Fider", - UserId: 1, - UserEmail: "jon.snow@got.com", - UserName: "Jon Snow", - TenantId: 1, - SignedUpAt: time.Now().Format(time.UnixDate), - BillingStatus: "active", - Subdomain: "got", + Name: "Fider", + UserId: 1, + UserEmail: "jon.snow@got.com", + UserName: "Jon Snow", + TenantId: 1, + SignedUpAt: time.Now().Format(time.UnixDate), + Plan: enum.PlanFree, + Subdomain: "got", } err := bus.Dispatch(ctx, createCompanyCmd) @@ -61,9 +61,9 @@ func TestUpdateTenant_Success(t *testing.T) { reset() updateCompanyCmd := &cmd.UserListUpdateCompany{ - Name: "Fider", - TenantId: 1, - BillingStatus: enum.BillingActive, + Name: "Fider", + TenantId: 1, + Plan: enum.PlanPro, } err := bus.Dispatch(ctx, updateCompanyCmd) @@ -75,15 +75,15 @@ func TestUpdateTenant_Success(t *testing.T) { Expect(httpclientmock.RequestsHistory[0].Header.Get("Content-Type")).Equals("application/json") } -func TestUpdateTenant_BillingStatusUpdatedIfSet(t *testing.T) { +func TestUpdateTenant_PlanUpdatedIfSet(t *testing.T) { RegisterT(t) env.Config.HostMode = "multi" reset() updateCompanyCmd := &cmd.UserListUpdateCompany{ - Name: "Fider", - TenantId: 1, - BillingStatus: enum.BillingActive, + Name: "Fider", + TenantId: 1, + Plan: enum.PlanPro, } err := bus.Dispatch(ctx, updateCompanyCmd) @@ -92,15 +92,15 @@ func TestUpdateTenant_BillingStatusUpdatedIfSet(t *testing.T) { Expect(httpclientmock.RequestsHistory).HasLen(1) body, _ := io.ReadAll(httpclientmock.RequestsHistory[0].Body) - containsBillingStatus := strings.Contains(string(body), "billing_status") - Expect(containsBillingStatus).IsTrue() + containsPlan := strings.Contains(string(body), "plan") + Expect(containsPlan).IsTrue() - // Also check we're using the enum string, not the int value - Expect(strings.Contains(string(body), "billing_status\":\"active\"")).IsTrue() + // Check we're sending the plan value + Expect(strings.Contains(string(body), "plan\":\"pro\"")).IsTrue() } -func TestUpdateTenant_BillingStatusNotUpdatedIfNotSet(t *testing.T) { +func TestUpdateTenant_PlanNotUpdatedIfNotSet(t *testing.T) { RegisterT(t) env.Config.HostMode = "multi" reset() @@ -116,8 +116,8 @@ func TestUpdateTenant_BillingStatusNotUpdatedIfNotSet(t *testing.T) { Expect(httpclientmock.RequestsHistory).HasLen(1) body, _ := io.ReadAll(httpclientmock.RequestsHistory[0].Body) - containsBillingStatus := strings.Contains(string(body), "billing_status") - Expect(containsBillingStatus).IsFalse() + containsPlan := strings.Contains(string(body), "plan") + Expect(containsPlan).IsFalse() } func TestUpdateTenant_NameShouldUpdateIfSet(t *testing.T) { diff --git a/app/tasks/userlist.go b/app/tasks/userlist.go index c5e7ea729..4709330b8 100644 --- a/app/tasks/userlist.go +++ b/app/tasks/userlist.go @@ -18,15 +18,21 @@ func UserListCreateCompany(tenant entity.Tenant, user entity.User) worker.Task { "Tenant": tenant.Name, "User": user.Email, }) + + plan := enum.PlanFree + if tenant.IsCommercial { + plan = enum.PlanPro + } + if err := bus.Dispatch(c, &cmd.UserListCreateCompany{ - Name: tenant.Name, - TenantId: tenant.ID, - SignedUpAt: time.Now().Format(time.RFC3339), - BillingStatus: enum.BillingTrial.String(), - Subdomain: tenant.Subdomain, - UserId: user.ID, - UserEmail: user.Email, - UserName: user.Name, + Name: tenant.Name, + TenantId: tenant.ID, + SignedUpAt: time.Now().Format(time.RFC3339), + Plan: plan, + Subdomain: tenant.Subdomain, + UserId: user.ID, + UserEmail: user.Email, + UserName: user.Name, }); err != nil { return c.Failure(err) } @@ -40,9 +46,9 @@ func UserListUpdateCompany(action *dto.UserListUpdateCompany) worker.Task { "Tenant": action.Name, }) if err := bus.Dispatch(c, &cmd.UserListUpdateCompany{ - TenantId: action.TenantID, - Name: action.Name, - BillingStatus: action.BillingStatus, + TenantId: action.TenantID, + Name: action.Name, + Plan: action.Plan, }); err != nil { return c.Failure(err) } diff --git a/commercial/LICENSE b/commercial/LICENSE new file mode 100644 index 000000000..4c4d6f386 --- /dev/null +++ b/commercial/LICENSE @@ -0,0 +1,27 @@ +Fider Commercial License + +Copyright (c) 2025 Fider + +This software and associated documentation files (the "Software") contained within this +"commercial" directory are proprietary and confidential to Fider. + +RESTRICTIONS: +- You may NOT use, copy, modify, merge, publish, distribute, sublicense, or sell copies + of this Software without a valid commercial license from Fider. +- You may NOT create derivative works based on this Software. +- You may NOT reverse engineer, decompile, or disassemble this Software. + +PERMITTED USES: +- You may view this Software's source code for evaluation purposes only. +- Licensed users with valid commercial agreements may use this Software in accordance + with their license terms. + +NO WARRANTY: +This Software is provided "as is", without warranty of any kind, express or implied, +including but not limited to the warranties of merchantability, fitness for a particular +purpose and noninfringement. + +For commercial licensing inquiries, please contact: [LICENSE_EMAIL_TO_BE_SET] + +All other code in this repository (outside this "commercial" directory) remains under +the AGPL-3.0 license as specified in the root LICENSE file. \ No newline at end of file diff --git a/commercial/components/ModerationIndicator.tsx b/commercial/components/ModerationIndicator.tsx new file mode 100644 index 000000000..0d6275fee --- /dev/null +++ b/commercial/components/ModerationIndicator.tsx @@ -0,0 +1,70 @@ +import React, { useState, useEffect } from "react" +import { Icon } from "@fider/components" +import { useFider } from "@fider/hooks" +import ThumbsUp from "@fider/assets/images/heroicons-thumbsup.svg" +import ThumbsDown from "@fider/assets/images/heroicons-thumbsdown.svg" +import { HStack } from "@fider/components/layout" + +export const ModerationIndicator = () => { + const fider = useFider() + const [count, setCount] = useState(0) + const [loading, setLoading] = useState(true) + + // Check if commercial license is available + const hasCommercialLicense = fider.session.tenant.isCommercial + + useEffect(() => { + const fetchCount = async () => { + try { + const response = await fetch("/_api/admin/moderation/count") + if (response.ok) { + const data = await response.json() + setCount(data.count || 0) + } + } catch (error) { + console.error("Failed to fetch moderation count:", error) + } finally { + setLoading(false) + } + } + + // Only fetch if user is admin/collaborator and moderation is enabled + if ((fider.session.user.isAdministrator || fider.session.user.isCollaborator) && fider.session.tenant.isModerationEnabled) { + fetchCount() + } else { + setLoading(false) + } + }, [fider.session.user, fider.session.tenant.isModerationEnabled]) + + // Don't show the indicator if commercial license is not available + if (!hasCommercialLicense) { + return null + } + + // Don't show the indicator if user is not admin/collaborator or moderation is disabled + if (!fider.session.user.isAdministrator && !fider.session.user.isCollaborator) { + return null + } + + if (!fider.session.tenant.isModerationEnabled) { + return null + } + + if (loading) { + return null + } + + if (count > 0) { + return ( + + + + + New ideas and comments waiting + + + ) + } else { + return <> + } +} diff --git a/commercial/handlers/apiv1/moderation.go b/commercial/handlers/apiv1/moderation.go new file mode 100644 index 000000000..a930ec17c --- /dev/null +++ b/commercial/handlers/apiv1/moderation.go @@ -0,0 +1,193 @@ +package apiv1 + +import ( + "strconv" + + "github.com/getfider/fider/app/models/cmd" + "github.com/getfider/fider/app/models/query" + "github.com/getfider/fider/app/pkg/bus" + "github.com/getfider/fider/app/pkg/log" + "github.com/getfider/fider/app/pkg/web" +) + +// ApprovePost approves a post +func ApprovePost() web.HandlerFunc { + return func(c *web.Context) error { + postID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return c.BadRequest(web.Map{"error": "Invalid post ID"}) + } + + if err := bus.Dispatch(c, &cmd.ApprovePost{PostID: postID}); err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{}) + } +} + +// DeclinePost declines (deletes) a post +func DeclinePost() web.HandlerFunc { + return func(c *web.Context) error { + postID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return c.BadRequest(web.Map{"error": "Invalid post ID"}) + } + + if err := bus.Dispatch(c, &cmd.DeclinePost{PostID: postID}); err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{}) + } +} + +// ApproveComment approves a comment +func ApproveComment() web.HandlerFunc { + return func(c *web.Context) error { + commentID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return c.BadRequest(web.Map{"error": "Invalid comment ID"}) + } + + if err := bus.Dispatch(c, &cmd.ApproveComment{CommentID: commentID}); err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{}) + } +} + +// DeclineComment declines (deletes) a comment +func DeclineComment() web.HandlerFunc { + return func(c *web.Context) error { + commentID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return c.BadRequest(web.Map{"error": "Invalid comment ID"}) + } + + if err := bus.Dispatch(c, &cmd.DeclineComment{CommentID: commentID}); err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{}) + } +} + +// DeclinePostAndBlock declines (deletes) a post and blocks the user +func DeclinePostAndBlock() web.HandlerFunc { + return func(c *web.Context) error { + + // Get the post that is being declined + postID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return c.BadRequest(web.Map{"error": "Invalid post ID"}) + } + + getPost := &query.GetPostByID{PostID: postID} + if err := bus.Dispatch(c, getPost); err != nil { + return c.Failure(err) + } + + // Call the existing BlockUser command inside the DeclinePostAndBlock handler + err = bus.Dispatch(c, &cmd.BlockUser{UserID: getPost.Result.User.ID}) + if err != nil { + return c.Failure(err) + } + + // Finally call the existing DeclinePost command + err = bus.Dispatch(c, &cmd.DeclinePost{PostID: postID}) + if err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{}) + } +} + +// DeclineCommentAndBlock declines (deletes) a comment and blocks the user +func DeclineCommentAndBlock() web.HandlerFunc { + return func(c *web.Context) error { + // Get the comment that is being declined + commentID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return c.BadRequest(web.Map{"error": "Invalid comment ID"}) + } + + getComment := &query.GetCommentByID{CommentID: commentID} + if err := bus.Dispatch(c, getComment); err != nil { + return c.Failure(err) + } + + // Call the existing BlockUser command inside the DeclinePostAndBlock handler + err = bus.Dispatch(c, &cmd.BlockUser{UserID: getComment.Result.User.ID}) + if err != nil { + return c.Failure(err) + } + + // Finally call the existing DeclinePost command + err = bus.Dispatch(c, &cmd.DeclineComment{CommentID: commentID}) + if err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{}) + } +} + +// ApprovePostAndVerify approves a post and verifies the user +func ApprovePostAndVerify() web.HandlerFunc { + return func(c *web.Context) error { + postID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return c.BadRequest(web.Map{"error": "Invalid post ID"}) + } + + // Get the post that is being approved + getPost := &query.GetPostByID{PostID: postID} + if err := bus.Dispatch(c, getPost); err != nil { + return c.Failure(err) + } + + // First approve the post + if err := bus.Dispatch(c, &cmd.ApprovePost{PostID: postID}); err != nil { + return c.Failure(err) + } + + // Then trust the user + if err := bus.Dispatch(c, &cmd.TrustUser{UserID: getPost.Result.User.ID}); err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{}) + } +} + +// ApproveCommentAndVerify approves a comment and verifies the user +func ApproveCommentAndVerify() web.HandlerFunc { + return func(c *web.Context) error { + log.Info(c, "Approving comment and verifying user") + commentID, err := strconv.Atoi(c.Param("id")) + if err != nil { + return c.BadRequest(web.Map{"error": "Invalid comment ID"}) + } + + // Get the comment that is being approved + getComment := &query.GetCommentByID{CommentID: commentID} + if err := bus.Dispatch(c, getComment); err != nil { + return c.Failure(err) + } + + // First approve the comment + if err := bus.Dispatch(c, &cmd.ApproveComment{CommentID: commentID}); err != nil { + return c.Failure(err) + } + + // Then trust the user + if err := bus.Dispatch(c, &cmd.TrustUser{UserID: getComment.Result.User.ID}); err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{}) + } +} \ No newline at end of file diff --git a/commercial/handlers/moderation.go b/commercial/handlers/moderation.go new file mode 100644 index 000000000..4aaa05b6c --- /dev/null +++ b/commercial/handlers/moderation.go @@ -0,0 +1,49 @@ +package handlers + +import ( + "net/http" + + "github.com/getfider/fider/app/models/query" + "github.com/getfider/fider/app/pkg/bus" + "github.com/getfider/fider/app/pkg/log" + "github.com/getfider/fider/app/pkg/web" +) + +// ModerationPage is the commercial moderation administration page +func ModerationPage() web.HandlerFunc { + return func(c *web.Context) error { + return c.Page(http.StatusOK, web.Props{ + Page: "Administration/pages/ContentModeration.page", + Title: "Moderation · Site Settings", + }) + } +} + +// GetModerationItems returns all unmoderated posts and comments +func GetModerationItems() web.HandlerFunc { + return func(c *web.Context) error { + q := &query.GetModerationItems{} + if err := bus.Dispatch(c, q); err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{ + "items": q.Result, + }) + } +} + +// GetModerationCount returns the count of items awaiting moderation +func GetModerationCount() web.HandlerFunc { + return func(c *web.Context) error { + log.Info(c, "Getting the moderation count commercial") + q := &query.GetModerationCount{} + if err := bus.Dispatch(c, q); err != nil { + return c.Failure(err) + } + + return c.Ok(web.Map{ + "count": q.Result, + }) + } +} diff --git a/commercial/init.go b/commercial/init.go new file mode 100644 index 000000000..02e0affa0 --- /dev/null +++ b/commercial/init.go @@ -0,0 +1,94 @@ +package commercial + +import ( + "context" + + "github.com/getfider/fider/app/handlers" + "github.com/getfider/fider/app/handlers/apiv1" + "github.com/getfider/fider/app/models/dto" + "github.com/getfider/fider/app/pkg/env" + "github.com/getfider/fider/app/pkg/log" + "github.com/getfider/fider/app/services" + "github.com/getfider/fider/app/services/license" + commercialHandlers "github.com/getfider/fider/commercial/handlers" + commercialApiv1 "github.com/getfider/fider/commercial/handlers/apiv1" +) + +// Commercial license service implementation +type commercialLicenseService struct { + isValid bool + tenantID int +} + +func init() { + ctx := context.Background() + var svc *commercialLicenseService + + if env.IsSingleHostMode() && env.Config.License.Key != "" { + // Self-hosted with license key: validate at startup + result := license.ValidateKey(env.Config.License.Key) + if !result.IsValid { + panic(result.Error) + } + + svc = &commercialLicenseService{ + isValid: true, + tenantID: result.TenantID, + } + + log.Infof(ctx, "Commercial license validated for tenant @{TenantID}", dto.Props{ + "TenantID": result.TenantID, + }) + } else { + // Multi-tenant hosted OR self-hosted without key + svc = &commercialLicenseService{isValid: env.IsMultiHostMode()} + } + + // Register commercial license service + services.License = svc + + // Always register commercial handlers (they check licensing internally) + handlers.RegisterModerationHandlers( + commercialHandlers.ModerationPage, + commercialHandlers.GetModerationItems, + commercialHandlers.GetModerationCount, + ) + + apiv1.RegisterModerationHandlers( + commercialApiv1.ApprovePost, + commercialApiv1.DeclinePost, + commercialApiv1.ApproveComment, + commercialApiv1.DeclineComment, + commercialApiv1.DeclinePostAndBlock, + commercialApiv1.DeclineCommentAndBlock, + commercialApiv1.ApprovePostAndVerify, + commercialApiv1.ApproveCommentAndVerify, + ) +} + +func (s *commercialLicenseService) IsCommercialFeatureEnabled(feature string) bool { + // In self-hosted mode, check license validity + if env.IsSingleHostMode() { + return s.isValid && feature == services.FeatureContentModeration + } + + // In multi-tenant mode, this is a global check + // Per-tenant checking happens via tenant.IsCommercial() + return true +} + +func (s *commercialLicenseService) GetLicenseInfo() *services.LicenseInfo { + if s.isValid { + return &services.LicenseInfo{ + IsValid: true, + Features: []string{services.FeatureContentModeration}, + LicenseHolder: "Licensed", + } + } + + return &services.LicenseInfo{ + IsValid: false, + Features: []string{}, + LicenseHolder: "Unlicensed", + } +} diff --git a/commercial/pages/Administration/ContentModeration.page.scss b/commercial/pages/Administration/ContentModeration.page.scss new file mode 100644 index 000000000..56796c134 --- /dev/null +++ b/commercial/pages/Administration/ContentModeration.page.scss @@ -0,0 +1,61 @@ +@use "~@fider/assets/styles/variables.scss" as *; + +#p-admin-moderation { + .page-title { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 1.5rem; + + .page-subtitle { + display: block; + font-size: 1rem; + font-weight: normal; + color: var(--colors-gray-600); + margin-top: 0.25rem; + } + } +} + +.c-moderation-page { + width: 100%; +} + +.c-moderation-item { + transition: all 0.3s ease; + + &:hover { + background-color: var(--colors-blue-100); + } + + &__actions { + transition: opacity 0.2s ease, visibility 0.2s ease; + + .c-moderation-item:hover & { + visibility: visible; + opacity: 1; + } + } + + &__post-reference { + color: var(--colors-gray-600); + + a { + color: var(--colors-blue-700); + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +} + +// Responsive adjustments +@media (max-width: 768px) { + .c-moderation-item { + &__actions { + visibility: visible; + opacity: 1; + } + } +} diff --git a/commercial/pages/Administration/ContentModeration.page.tsx b/commercial/pages/Administration/ContentModeration.page.tsx new file mode 100644 index 000000000..b13e2e6e6 --- /dev/null +++ b/commercial/pages/Administration/ContentModeration.page.tsx @@ -0,0 +1,307 @@ +import "./ContentModeration.page.scss" + +import React, { useState, useEffect } from "react" +import { Button, Avatar, Loader, Icon, Markdown } from "@fider/components/common" +import { Header } from "@fider/components" +import { HStack, VStack } from "@fider/components/layout" +import { actions, chopString, http, notify } from "@fider/services" +import { User, UserStatus } from "@fider/models" +import { useFider } from "@fider/hooks" +import { Trans } from "@lingui/react/macro" +import IconCheck from "@fider/assets/images/heroicons-check.svg" +import IconX from "@fider/assets/images/heroicons-x.svg" +import IconShieldCheck from "@fider/assets/images/heroicons-shieldcheck.svg" +import IconBan from "@fider/assets/images/heroicons-x-circle.svg" +import { Moment } from "@fider/components/common" + +interface ModerationItem { + type: "post" | "comment" + id: number + postId?: number + postNumber?: number + postSlug?: string + title?: string + content: string + user: User + createdAt: string + postTitle?: string +} + +interface ContentModerationPageState { + items: ModerationItem[] + loading: boolean +} + +const ContentModerationPage = () => { + const [state, setState] = useState({ + items: [], + loading: true, + }) + const fider = useFider() + + const fetchItems = async () => { + setState((prev) => ({ ...prev, loading: true })) + const result = await http.get<{ items: ModerationItem[] }>("/_api/admin/moderation/items") + if (result.ok) { + setState({ items: result.data.items, loading: false }) + } else { + setState((prev) => ({ ...prev, loading: false })) + notify.error(Failed to fetch moderation items) + } + } + + useEffect(() => { + fetchItems() + }, []) + + const handleApprovePost = async (postId: number) => { + const result = await actions.approvePost(postId) + if (result.ok) { + notify.success(Post published successfully) + setState((prev) => ({ + ...prev, + items: prev.items.filter((item) => !(item.type === "post" && item.id === postId)), + })) + } else { + notify.error(Failed to publish post) + } + } + + const handleDeclinePost = async (postId: number) => { + const result = await actions.declinePost(postId) + if (result.ok) { + notify.success(Post deleted successfully) + setState((prev) => ({ + ...prev, + items: prev.items.filter((item) => !(item.type === "post" && item.id === postId)), + })) + } else { + notify.error(Failed to delete post) + } + } + + const handleApproveComment = async (commentId: number) => { + const result = await actions.approveComment(commentId) + if (result.ok) { + notify.success(Comment published successfully) + setState((prev) => ({ + ...prev, + items: prev.items.filter((item) => !(item.type === "comment" && item.id === commentId)), + })) + } else { + notify.error(Failed to publish comment) + } + } + + const handleDeclineComment = async (commentId: number) => { + const result = await actions.declineComment(commentId) + if (result.ok) { + notify.success(Comment deleted successfully) + setState((prev) => ({ + ...prev, + items: prev.items.filter((item) => !(item.type === "comment" && item.id === commentId)), + })) + } else { + notify.error(Failed to delete comment) + } + } + + const handleApprovePostAndVerify = async (postId: number) => { + const result = await actions.approvePostAndVerify(postId) + if (result.ok) { + notify.success(Post published and user verified) + setState((prev) => ({ + ...prev, + items: prev.items.filter((item) => !(item.type === "post" && item.id === postId)), + })) + } else { + notify.error(Failed to publish post and verify user) + } + } + + const handleDeclinePostAndBlock = async (postId: number) => { + const result = await actions.declinePostAndBlock(postId) + if (result.ok) { + notify.success(Post deleted and user blocked) + setState((prev) => ({ + ...prev, + items: prev.items.filter((item) => !(item.type === "post" && item.id === postId)), + })) + } else { + notify.error(Failed to delete post and block user) + } + } + + const handleApproveCommentAndVerify = async (commentId: number) => { + const result = await actions.approveCommentAndVerify(commentId) + if (result.ok) { + notify.success(Comment published and user verified) + setState((prev) => ({ + ...prev, + items: prev.items.filter((item) => !(item.type === "comment" && item.id === commentId)), + })) + } else { + notify.error(Failed to publish comment and verify user) + } + } + + const handleDeclineCommentAndBlock = async (commentId: number) => { + const result = await actions.declineCommentAndBlock(commentId) + if (result.ok) { + notify.success(Comment deleted and user blocked) + setState((prev) => ({ + ...prev, + items: prev.items.filter((item) => !(item.type === "comment" && item.id === commentId)), + })) + } else { + notify.error(Failed to delete comment and block user) + } + } + + const renderDivider = (title: string, count: number) => { + return ( + +
+ {title} ({count}) +
+
+
+ ) + } + + const handlePostClick = (link: string) => { + window.location.href = link + } + + const renderModerationItem = (item: ModerationItem) => { + const containerClasses = `c-moderation-item flex flex-y p-3 rounded-md hover clickable` + + const title = item.type == "post" ? item.title : item.postTitle + const link = item.type == "post" ? `/posts/${item.postNumber}/${item.postSlug}` : `/posts/${item.postNumber}/${item.postSlug}#comment-${item.id}` + const blocked = item.user.status === UserStatus.Blocked && blocked + + return ( +
handlePostClick(link)}> +
+ + + + + <> + + + {item.user.name} <{item.user.email}> + + {blocked} + + {item.type === "post" &&

{title}

} +

+ +

+ {item.type === "comment" &&

{title}

} + + +
e.stopPropagation()}> + + + + + + +
+
+
+ +
+
+
+ ) + } + + const posts = state.items.filter((item) => item.type === "post") + const comments = state.items.filter((item) => item.type === "comment") + + return ( + <> +
+
+

+ Moderation Queue +

+

+ These ideas and comments are from people outside of your trusted users list, you decide if they get published. +

+ +
+ {state.loading ? ( + + ) : state.items.length === 0 ? ( +
+

+ All content has been moderated. You're all caught up! +

+
+ ) : ( +
+ {posts.length > 0 && ( + <> + {renderDivider("New ideas", posts.length)} +
+
{posts.map(renderModerationItem)}
+
+ + )} + + {comments.length > 0 && ( + <> + {renderDivider("New comments", comments.length)} +
+
{comments.map(renderModerationItem)}
+
+ + )} +
+ )} +
+
+ + ) +} + +export default ContentModerationPage diff --git a/commercial/services/sqlstore/postgres/moderation.go b/commercial/services/sqlstore/postgres/moderation.go new file mode 100644 index 000000000..b72d30380 --- /dev/null +++ b/commercial/services/sqlstore/postgres/moderation.go @@ -0,0 +1,292 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "github.com/getfider/fider/app" + "github.com/getfider/fider/app/models/cmd" + "github.com/getfider/fider/app/models/entity" + "github.com/getfider/fider/app/models/enum" + "github.com/getfider/fider/app/models/query" + "github.com/getfider/fider/app/pkg/bus" + "github.com/getfider/fider/app/pkg/dbx" + "github.com/getfider/fider/app/pkg/errors" + "github.com/getfider/fider/app/services/sqlstore/dbEntities" +) + +func ApprovePost(ctx context.Context, c *cmd.ApprovePost) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + _, err := trx.Execute(` + UPDATE posts SET is_approved = true + WHERE id = $1 AND tenant_id = $2`, c.PostID, tenant.ID) + if err != nil { + return errors.Wrap(err, "failed to approve post") + } + return nil + }) +} + +func DeclinePost(ctx context.Context, c *cmd.DeclinePost) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + // Use existing query to get the post + getPost := &query.GetPostByID{PostID: c.PostID} + if err := bus.Dispatch(ctx, getPost); err != nil { + return err + } + + // Use SetPostResponse to properly delete the post + setResponse := &cmd.SetPostResponse{ + Post: getPost.Result, + Text: "Post declined during moderation", + Status: enum.PostDeleted, + } + + return bus.Dispatch(ctx, setResponse) + }) +} + +func ApproveComment(ctx context.Context, c *cmd.ApproveComment) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + _, err := trx.Execute(` + UPDATE comments SET is_approved = true + WHERE id = $1 AND tenant_id = $2`, c.CommentID, tenant.ID) + if err != nil { + return errors.Wrap(err, "failed to approve comment") + } + return nil + }) +} + +func DeclineComment(ctx context.Context, c *cmd.DeclineComment) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + // Use existing delete command + deleteComment := &cmd.DeleteComment{CommentID: c.CommentID} + return bus.Dispatch(ctx, deleteComment) + }) +} + +func BulkApproveItems(ctx context.Context, c *cmd.BulkApproveItems) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + if len(c.PostIDs) > 0 { + postIDsStr := "" + for i, id := range c.PostIDs { + if i > 0 { + postIDsStr += "," + } + postIDsStr += fmt.Sprintf("%d", id) + } + _, err := trx.Execute(fmt.Sprintf(` + UPDATE posts SET is_approved = true + WHERE id IN (%s) AND tenant_id = $1`, postIDsStr), tenant.ID) + if err != nil { + return errors.Wrap(err, "failed to bulk approve posts") + } + } + + if len(c.CommentIDs) > 0 { + commentIDsStr := "" + for i, id := range c.CommentIDs { + if i > 0 { + commentIDsStr += "," + } + commentIDsStr += fmt.Sprintf("%d", id) + } + _, err := trx.Execute(fmt.Sprintf(` + UPDATE comments SET is_approved = true + WHERE id IN (%s) AND tenant_id = $1`, commentIDsStr), tenant.ID) + if err != nil { + return errors.Wrap(err, "failed to bulk approve comments") + } + } + + return nil + }) +} + +func BulkDeclineItems(ctx context.Context, c *cmd.BulkDeclineItems) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + if len(c.PostIDs) > 0 { + // Use existing commands to properly delete each post + for _, postID := range c.PostIDs { + // Use existing query to get the post + getPost := &query.GetPostByID{PostID: postID} + if err := bus.Dispatch(ctx, getPost); err != nil { + return errors.Wrap(err, "failed to get post for bulk decline") + } + + // Use SetPostResponse to properly delete the post + setResponse := &cmd.SetPostResponse{ + Post: getPost.Result, + Text: "Post declined during bulk moderation", + Status: enum.PostDeleted, + } + + if err := bus.Dispatch(ctx, setResponse); err != nil { + return errors.Wrap(err, "failed to bulk decline post") + } + } + } + + if len(c.CommentIDs) > 0 { + // Use existing delete command for each comment + for _, commentID := range c.CommentIDs { + deleteComment := &cmd.DeleteComment{CommentID: commentID} + if err := bus.Dispatch(ctx, deleteComment); err != nil { + return errors.Wrap(err, "failed to bulk decline comment") + } + } + } + + return nil + }) +} + +type dbModerationPost struct { + ID int `db:"id"` + Number int `db:"number"` + Title string `db:"title"` + Slug string `db:"slug"` + Description string `db:"description"` + CreatedAt time.Time `db:"created_at"` + User *dbEntities.User `db:"user"` +} + +type dbModerationComment struct { + ID int `db:"id"` + PostID int `db:"post_id"` + PostNumber int `db:"post_number"` + PostSlug string `db:"post_slug"` + Content string `db:"content"` + CreatedAt time.Time `db:"created_at"` + User *dbEntities.User `db:"user"` + PostTitle string `db:"post_title"` +} + +func GetModerationItems(ctx context.Context, q *query.GetModerationItems) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + q.Result = make([]*query.ModerationItem, 0) + + // Get unmoderated posts + var posts []*dbModerationPost + + err := trx.Select(&posts, ` + SELECT p.id, p.number, p.title, p.slug, p.description, p.created_at, + u.id AS user_id, + u.name AS user_name, + u.email AS user_email, + u.role AS user_role, + u.status AS user_status, + u.avatar_type AS user_avatar_type, + u.avatar_bkey AS user_avatar_bkey + FROM posts p + INNER JOIN users u ON u.id = p.user_id AND u.tenant_id = p.tenant_id + WHERE p.tenant_id = $1 AND p.is_approved = false and p.status <> $2 + ORDER BY p.created_at DESC`, tenant.ID, enum.PostDeleted) + if err != nil { + return errors.Wrap(err, "failed to get unmoderated posts") + } + + for _, post := range posts { + userWithEmail := &entity.UserWithEmail{ + User: post.User.ToModel(ctx), + } + + q.Result = append(q.Result, &query.ModerationItem{ + Type: "post", + ID: post.ID, + PostNumber: post.Number, + PostSlug: post.Slug, + Title: post.Title, + Content: post.Description, + CreatedAt: post.CreatedAt, + User: userWithEmail, + }) + } + + // Get unmoderated comments + var comments []*dbModerationComment + + err = trx.Select(&comments, ` + SELECT c.id, c.post_id, p.number as post_number, p.slug as post_slug, c.content, c.created_at, + u.id AS user_id, + u.name AS user_name, + u.email AS user_email, + u.role AS user_role, + u.status AS user_status, + u.avatar_type AS user_avatar_type, + u.avatar_bkey AS user_avatar_bkey, + p.title as post_title + FROM comments c + INNER JOIN users u ON u.id = c.user_id AND u.tenant_id = c.tenant_id + INNER JOIN posts p ON p.id = c.post_id AND p.tenant_id = c.tenant_id + WHERE c.tenant_id = $1 AND c.is_approved = false and p.status <> $2 + AND c.deleted_at IS NULL + ORDER BY c.created_at DESC`, tenant.ID, enum.PostDeleted) + if err != nil { + return errors.Wrap(err, "failed to get unmoderated comments") + } + + for _, comment := range comments { + + userWithEmail := &entity.UserWithEmail{ + User: comment.User.ToModel(ctx), + } + + q.Result = append(q.Result, &query.ModerationItem{ + Type: "comment", + ID: comment.ID, + PostID: comment.PostID, + PostNumber: comment.PostNumber, + PostSlug: comment.PostSlug, + Content: comment.Content, + CreatedAt: comment.CreatedAt, + PostTitle: comment.PostTitle, + User: userWithEmail, + }) + } + + return nil + }) +} + +func GetModerationCount(ctx context.Context, q *query.GetModerationCount) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + var count int + + err := trx.Get(&count, ` + SELECT + (SELECT COUNT(*) FROM posts WHERE tenant_id = $1 AND is_approved = false and status <> $2) + + (SELECT COUNT(*) FROM comments c JOIN posts p on c.post_id = p.id WHERE p.tenant_id = $1 AND c.is_approved = false AND p.status <> $2 AND c.deleted_at IS NULL) + `, tenant.ID, enum.PostDeleted) + + if err != nil { + return errors.Wrap(err, "failed to get moderation count") + } + + q.Result = count + return nil + }) +} + +func TrustUser(ctx context.Context, c *cmd.TrustUser) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + // Trust and unblock the user (can't be both blocked and trusted) + _, err := trx.Execute(` + UPDATE users SET is_trusted = true, status = $1 + WHERE id = $2 AND tenant_id = $3`, enum.UserActive, c.UserID, tenant.ID) + if err != nil { + return errors.Wrap(err, "failed to trust user") + } + + return nil + }) +} + +func using(ctx context.Context, handler func(*dbx.Trx, *entity.Tenant, *entity.User) error) error { + trx, _ := ctx.Value(app.TransactionCtxKey).(*dbx.Trx) + tenant, _ := ctx.Value(app.TenantCtxKey).(*entity.Tenant) + user, _ := ctx.Value(app.UserCtxKey).(*entity.User) + return handler(trx, tenant, user) +} diff --git a/commercial/services/sqlstore/postgres/postgres.go b/commercial/services/sqlstore/postgres/postgres.go new file mode 100644 index 000000000..3b88cae41 --- /dev/null +++ b/commercial/services/sqlstore/postgres/postgres.go @@ -0,0 +1,37 @@ +package postgres + +import ( + "github.com/getfider/fider/app/pkg/bus" +) + +func init() { + bus.Register(CommercialService{}) +} + +type CommercialService struct{} + +func (s CommercialService) Name() string { + return "Commercial PostgreSQL" +} + +func (s CommercialService) Category() string { + return "commercial-sqlstore" +} + +func (s CommercialService) Enabled() bool { + return true +} + +func (s CommercialService) Init() { + // Register commercial moderation handlers + // These should override the open source stub handlers + bus.AddHandler(ApprovePost) + bus.AddHandler(DeclinePost) + bus.AddHandler(ApproveComment) + bus.AddHandler(DeclineComment) + bus.AddHandler(BulkApproveItems) + bus.AddHandler(BulkDeclineItems) + bus.AddHandler(GetModerationItems) + bus.AddHandler(GetModerationCount) + bus.AddHandler(TrustUser) +} diff --git a/docker-compose.yml b/docker-compose.yml index 3a920fe09..35e4b64f2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,7 +20,7 @@ services: pgtest: container_name: fider_pgtest restart: always - image: postgres:12 + image: postgres:17 ports: - "5566:5432" environment: diff --git a/esbuild.config.js b/esbuild.config.js index 8fbef0df3..1fca15936 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -72,6 +72,11 @@ esbuild }, inject: ["./esbuild-shim.js"], outfile: "ssr.js", + alias: { + "@fider": "./public", + "@commercial": "./commercial", + "@locale": "./locale", + }, plugins: [emptyCSS, emptySVG, babelPlugin()], }) .catch(() => process.exit(1)) diff --git a/go.mod b/go.mod index bdc3e3d6b..cc9783e5e 100644 --- a/go.mod +++ b/go.mod @@ -185,6 +185,7 @@ require ( github.com/stbenjam/no-sprintf-host-port v0.1.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.9.0 // indirect + github.com/stripe/stripe-go/v83 v83.2.1 // indirect github.com/subosito/gotenv v1.4.1 // indirect github.com/t-yuki/gocover-cobertura v0.0.0-20180217150009-aaee18c8195c // indirect github.com/tdakkota/asciicheck v0.2.0 // indirect diff --git a/go.sum b/go.sum index c565fe631..3086e20d9 100644 --- a/go.sum +++ b/go.sum @@ -583,6 +583,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stripe/stripe-go/v83 v83.2.1 h1:8WPhpMjr8VyMWKUsCMoVvlWxYazuL5edajKX/RulfbA= +github.com/stripe/stripe-go/v83 v83.2.1/go.mod h1:nRyDcLrJtwPPQUnKAFs9Bt1NnQvNhNiF6V19XHmPISE= github.com/subosito/gotenv v1.4.1 h1:jyEFiXpy21Wm81FBN71l9VoMMV8H8jG+qIK3GCpY6Qs= github.com/subosito/gotenv v1.4.1/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= github.com/t-yuki/gocover-cobertura v0.0.0-20180217150009-aaee18c8195c h1:+aPplBwWcHBo6q9xrfWdMrT9o4kltkmmvpemgIjep/8= diff --git a/lingui.config.js b/lingui.config.js index a10bf10b0..d4bd3eeb6 100644 --- a/lingui.config.js +++ b/lingui.config.js @@ -7,7 +7,7 @@ export default { catalogs: [ { path: "/locale/{locale}/client", - include: ["/public/**/*.{ts,tsx}"], + include: ["/public/**/*.{ts,tsx}", "/commercial/**/*.{ts,tsx}"], }, ], orderBy: "messageId", diff --git a/locale/ar/client.json b/locale/ar/client.json index 2af94104b..6cc8ea714 100644 --- a/locale/ar/client.json +++ b/locale/ar/client.json @@ -4,13 +4,15 @@ "action.close": "إغلاق", "action.commentsfeed": "تغذية التعليقات", "action.confirm": "تأكيد", - "action.continue": "يكمل", "action.copylink": "نسخ الرابط", "action.delete": "حذف", + "action.delete.block": "", "action.edit": "تعديل", "action.markallasread": "تمييز الكل كمقروءة", "action.ok": "حسناً", "action.postsfeed": "تغذية المنشورات", + "action.publish": "", + "action.publish.verify": "", "action.respond": "رد", "action.save": "احفظ", "action.signin": "تسجيل الدخول", @@ -18,7 +20,6 @@ "action.submit": "إرسال", "action.vote": "صوت لهذه الفكرة", "action.voted": "تم التصويت!", - "d41FkJ": "{count, plural, zero {}one {# وسم} two {# وسوم} few {# وسوم} many {# وسوم} other {# وسوم}}", "editor.markdownmode": "الانتقال إلى محرر النصوص (Markdown)", "editor.richtextmode": "التبديل إلى محرر نص منسق", "enum.poststatus.completed": "اكتمل", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "يسعدنا سماع أفكارك.\n\nكيف يمكننا أن نكون أفضل؟ هذا هو المكان المناسب للتصويت والمناقشة ومشاركة الأفكار.", "home.lonely.suggestion": "ننصح بإنشاء <0>٣ اقتراحات على الأقل قبل مشاركة هذا الموقع. المحتوى المبدئي مهم لجذب جمهورك.", "home.lonely.text": "لم يتم إنشاء منشورات بعد.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "ملك", "home.postfilter.label.status": "حالة", - "home.postfilter.label.view": "عرض", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "الأكثر مناقشة", "home.postfilter.option.mostwanted": "الأكثر طلبا", "home.postfilter.option.myposts": "منشوراتي", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "غير مُعَلَّم", "home.postfilter.option.recent": "الأحدث", "home.postfilter.option.trending": "الشائع", - "home.postinput.description.placeholder": "صف اقتراحك (اختياري)", "home.postscontainer.label.noresults": "لا توجد نتائج تطابق بحثك، جرب شيئا مختلفا.", "home.postscontainer.label.viewmore": "عرض المزيد من المنشورات", "home.postscontainer.query.placeholder": "بحث", "home.postsort.label": "الترتيب بحسب:", - "home.similar.subtitle": "فكر في التصويت على المشاركات الحالية بدلاً من ذلك.", "home.similar.title": "منشورات مماثلة", - "home.tagsfilter.label.with": "مع", - "home.tagsfilter.selected.none": "أي وسم", - "label.actions": "أجراءات", "label.addtags": "أضف العلامات...", "label.avatar": "الصورة الرمزية", "label.custom": "تخصيص", - "label.description": "الوصف", "label.discussion": "المناقشة", "label.edittags": "تعديل الوسوم", "label.email": "البريد الإلكتروني", @@ -77,36 +74,27 @@ "label.following": "متابعين", "label.gravatar": "الصورة الرمزية Gravatar", "label.letter": "خطاب", - "label.moderation": "إشراف", "label.name": "الاسم", "label.none": "لا شيء", - "label.notagsavailable": "لا توجد وسوم متاحة", - "label.notagsselected": "لم يتم تحديد أي علامات", "label.notifications": "الإشعارات", "label.or": "أو", "label.searchtags": "علامات البحث...", - "label.selecttags": "حدد وسوم...", "label.subscribe": "إشترِك", "label.tags": "وسوم", - "label.unfollow": "إلغاء المتابعة", "label.unread": "غير مقروء", "label.unsubscribe": "إلغاء الاشتراك", "label.voters": "المصوتون", "labels.notagsavailable": "لا توجد وسوم متاحة", - "labels.notagsselected": "لم يتم تحديد أي علامات", "legal.agreement": "لقد قرأت <0/> و<1/> وأوافق على ذلك.", - "legal.notice": "من خلال تسجيل الدخول، أنت توافق على <2/><0/> و <1/>.", + "legal.notice": "من خلال تسجيل الدخول، أنت توافق على <0/><1/> و <2/>.", "legal.privacypolicy": "سياسة الخصوصية", "legal.termsofservice": "شروط الخدمة", "linkmodal.insert": "إدراج الرابط", "linkmodal.text.label": "النص المراد عرضه", "linkmodal.text.placeholder": "أدخل نص الرابط", - "linkmodal.text.required": "النص مطلوب", "linkmodal.title": "إدراج الرابط", - "linkmodal.url.invalid": "الرجاء إدخال عنوان URL صالح", "linkmodal.url.label": "عنوان URL", "linkmodal.url.placeholder": "", - "linkmodal.url.required": "مطلوب عنوان URL", "menu.administration": "آلإدارة", "menu.mysettings": "إعداداتي", "menu.signout": "تسجيل الخروج", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "لم يتم العثور على مستخدمين مطابقين ل <0>{0}.", "modal.showvotes.query.placeholder": "البحث عن المستخدمين بالاسم...", "modal.signin.header": "أرسل ملاحظاتك", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "تمت قراءتها خلال آخر 30 يومًا.", "mynotifications.message.nounread": "لا توجد إشعارات غير مقروءة.", "mynotifications.page.subtitle": "ابقَ على اطلاع بما يحدث", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "البريد الإلكتروني", "mysettings.notification.channelweb": "موقع", "mysettings.notification.event.discussion": "المناقشة", - "mysettings.notification.event.discussion.staff": "التعليقات مفعّلة على جميع المنشورات ما لم تقم بإلغاء الاشتراك بشكل فردي", - "mysettings.notification.event.discussion.visitors": "التعليقات على المنشورات التي اشتركت بها", "mysettings.notification.event.mention": "الإشارات", "mysettings.notification.event.newpost": "منشور جديد", - "mysettings.notification.event.newpost.staff": "المنشورات الجديدة على هذا الموقع", - "mysettings.notification.event.newpost.visitors": "المنشورات الجديدة على هذا الموقع", "mysettings.notification.event.newpostcreated": "تمت إضافة فكرتك 👍", "mysettings.notification.event.statuschanged": "تم تغيير الحالة", - "mysettings.notification.event.statuschanged.staff": "تغيير الحالة على جميع المنشورات ما لم يتم إلغاء الاشتراك بها بشكل فردي", - "mysettings.notification.event.statuschanged.visitors": "تغيير الحالة على المنشورات التي اشتركت بها", - "mysettings.notification.message.emailonly": "سوف تتلقى <0>البريد الإلكتروني إشعارات حول {about}.", - "mysettings.notification.message.none": "لن <0>تتلقى أي إشعار بشأن هذا الحدث.", - "mysettings.notification.message.webandemail": "سوف تتلقى <0>موقع و <1>البريد الإلكتروني إشعارات حول {about}.", - "mysettings.notification.message.webonly": "سوف تتلقى موقع<0> إشعارات حول {about}.", "mysettings.notification.title": "استخدم اللوحة التالية لاختيار الأحداث التي ترغب في تلقي الإشعار", "mysettings.page.subtitle": "إدارة إعدادات ملفك الشخصي", "mysettings.page.title": "إعدادات", - "newpost.modal.addimage": "إضافة الصور", "newpost.modal.description.placeholder": "أخبرنا عنها. اشرحها بالتفصيل، لا تتردد، فكلما زادت المعلومات كان ذلك أفضل.", "newpost.modal.submit": "أرسل فكرتك", "newpost.modal.title": "شارك بفكرتك...", "newpost.modal.title.label": "أعط فكرتك عنوانًا", "newpost.modal.title.placeholder": "شيء قصير وموجز، لخصه في بضع كلمات", "page.backhome": "خذني إلى <0>{0} الصفحة الرئيسية.", - "page.notinvited.text": "لم نتمكن من العثور على حساب لعنوان بريدك الإلكتروني.", - "page.notinvited.title": "لم تتم دعوتك", "page.pendingactivation.didntreceive": "لم تستلم البريد الإلكتروني؟", "page.pendingactivation.resend": "إعادة إرسال رسالة التحقق", "page.pendingactivation.resending": "إعادة الإرسال...", "page.pendingactivation.text": "أرسلنا لك رسالة تأكيد بالبريد الإلكتروني مع رابط لتفعيل موقعك.", "page.pendingactivation.text2": "الرجاء التحقق من صندوق الوارد الخاص بك لتفعيله.", "page.pendingactivation.title": "حسابك في انتظار التفعيل", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "فشل نسخ رابط التعليق، يرجى نسخ رابط الصفحة", "showpost.comment.copylink.success": "تم نسخ رابط التعليق إلى الحافظة", "showpost.comment.unknownhighlighted": "معرف تعليق غير صالح #{id}", "showpost.commentinput.placeholder": "اترك تعليق", "showpost.copylink.success": "تم نسخ الرابط إلى الحافظة", - "showpost.discussionpanel.emptymessage": "لم يعلق أحد بعد.", - "showpost.label.author": "نشرت بواسطة <0/> · <1/>", "showpost.message.nodescription": "لا يوجد وصف.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "هذه العملية <0>لا يمكن التراجع عنها.", "showpost.moderationpanel.text.placeholder": "لماذا تقوم بحذف هذا المنشور؟ (اختياري)", - "showpost.mostwanted.comments": "{count, plural, zero {}one {# تعليق} two {# تعليقات} few {# تعليقات} many {# تعليقات} other {# تعليقات}}", - "showpost.mostwanted.votes": "{count, plural, one {# صوت} other {# أصوات}}", "showpost.notificationspanel.message.subscribed": "أنت تتلقى إشعارات عن النشاط على هذا المنشور.", "showpost.notificationspanel.message.unsubscribed": "لن تتلقى أي إشعار بشأن هذا المنشور.", "showpost.postsearch.numofvotes": "{0} أصوات", "showpost.postsearch.query.placeholder": "البحث في المنشور الأصلي...", - "showpost.response.date": "تغيرت الحالة إلى {status} على {statusDate}", "showpost.responseform.message.mergedvotes": "سيتم دمج التصويتات من هذا المنشور في المنشور الأصلية.", "showpost.responseform.text.placeholder": "ما الذي يجري مع هذا المنشور؟ أخبر المستخدمين ما هي خططك...", "showpost.votespanel.more": "+{extraVotesCount} أكثر", @@ -208,7 +216,6 @@ "signin.code.instruction": "الرجاء كتابة الرمز الذي أرسلناه للتو إلى <0>{email}", "signin.code.placeholder": "اكتب الكود هنا", "signin.code.sent": "لقد تم إرسال رمز جديد إلى بريدك الإلكتروني.", - "signin.code.submit": "يُقدِّم", "signin.email.placeholder": "البريد الإلكتروني", "signin.message.email": "متابعة بالبريد الإلكتروني", "signin.message.emaildisabled": "تم تعطيل مصادقة البريد الإلكتروني من قبل المسؤول. إذا كان لديك حساب مسؤول وتحتاج إلى تجاوز هذا التقييد، الرجاء <0>انقر هنا .", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "تسجيل الدخول بواسطة", "signin.name.placeholder": "اسمك", "validation.custom.maxattachments": "يُسمح بحد أقصى {number} من المرفقات.", - "validation.custom.maximagesize": "يجب أن يكون حجم الصورة أصغر من {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, zero {}one {# وسم} two {# وسوم} few {# وسوم} many {# وسوم} other {# وسوم}}", - "signin.code.expired": "انتهت صلاحية هذا الرمز أو تم استخدامه مسبقًا. يُرجى طلب رمز جديد.", - "signin.code.invalid": "الرمز الذي أدخلته غير صحيح. يُرجى المحاولة مرة أخرى." -} \ No newline at end of file + "validation.custom.maximagesize": "يجب أن يكون حجم الصورة أصغر من {kilobytes}KB." +} diff --git a/locale/cs/client.json b/locale/cs/client.json index 096a63e78..3449580c5 100644 --- a/locale/cs/client.json +++ b/locale/cs/client.json @@ -84,7 +84,7 @@ "label.voters": "Voliči", "labels.notagsavailable": "Žádné štítky nejsou k dispozici", "legal.agreement": "Přečetl jsem si a souhlasím s <0/> a <1/>.", - "legal.notice": "Přihlášením souhlasíte s <2/><0/> a <1/>.", + "legal.notice": "Přihlášením souhlasíte s <0/><1/> a <2/>.", "legal.privacypolicy": "Zásady ochrany osobních údajů", "legal.termsofservice": "Podmínky služby", "menu.administration": "Správa", diff --git a/locale/de/client.json b/locale/de/client.json index d0933ec7d..4ffc0b2d2 100644 --- a/locale/de/client.json +++ b/locale/de/client.json @@ -4,13 +4,15 @@ "action.close": "Schließen", "action.commentsfeed": "Kommentar-Feed", "action.confirm": "Bestätigen", - "action.continue": "Weitermachen", "action.copylink": "Link kopieren", "action.delete": "Löschen", + "action.delete.block": "", "action.edit": "Bearbeiten", "action.markallasread": "Alle als gelesen markieren", "action.ok": "OK", "action.postsfeed": "Beiträge-Feed", + "action.publish": "", + "action.publish.verify": "", "action.respond": "Antworten", "action.save": "Sichern", "action.signin": "Anmelden", @@ -18,7 +20,6 @@ "action.submit": "Absenden", "action.vote": "Abstimmen", "action.voted": "Abgestimmt!", - "d41FkJ": "{count, plural, one {# Tag} other {# Tags}}", "editor.markdownmode": "Zum Markdown-Editor wechseln", "editor.richtextmode": "Zum Rich-Text-Editor wechseln", "enum.poststatus.completed": "Erledigt", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "Wir würden gerne erfahren, worüber Du nachdenkst.\n\nWas können wir verbessern? Hier kannst Du abstimmen, diskutieren und neue Ideen vorschlagen.", "home.lonely.suggestion": "Es wird empfohlen, dass du <0>mindestens 3 Vorschläge hier erstellst, bevor du diese Seite teilst. Der anfängliche Inhalt ist wichtig, um die Interaktion mit deiner Zielgruppe zu fördern.", "home.lonely.text": "Es wurden noch keine Beiträge erstellt.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "Meine Aktivität", "home.postfilter.label.status": "Status", - "home.postfilter.label.view": "Anzeigen", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "Am häufigsten diskutiert", "home.postfilter.option.mostwanted": "Meist gefragt", "home.postfilter.option.myposts": "Meine Beiträge", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "Nicht getaggt", "home.postfilter.option.recent": "Neueste", "home.postfilter.option.trending": "Beliebt", - "home.postinput.description.placeholder": "Beschreibe deinen Vorschlag (optional)", "home.postscontainer.label.noresults": "Keine Ergebnisse entsprechen deiner Suche, versuche etwas anderes.", "home.postscontainer.label.viewmore": "Weitere Beiträge anschauen", "home.postscontainer.query.placeholder": "Suche", "home.postsort.label": "Sortieren nach:", - "home.similar.subtitle": "Stimme stattdessen für einen bestehenden Beitrag ab.", "home.similar.title": "Ähnliche Beiträge", - "home.tagsfilter.label.with": "mit", - "home.tagsfilter.selected.none": "Jegliche Tags", - "label.actions": "Aktionen", "label.addtags": "Tags hinzufügen...", "label.avatar": "Profilbild", "label.custom": "Benutzerdefiniert", - "label.description": "Beschreibung", "label.discussion": "Diskussion", "label.edittags": "Tags bearbeiten", "label.email": "E-Mail", @@ -77,36 +74,27 @@ "label.following": "Folgend", "label.gravatar": "Gravatar", "label.letter": "Buchstabe", - "label.moderation": "Moderieren", "label.name": "Name", "label.none": "Keine", - "label.notagsavailable": "Keine Tags verfügbar", - "label.notagsselected": "Keine Tags ausgewählt", "label.notifications": "Benachrichtigungen", "label.or": "ODER", "label.searchtags": "Tags suchen...", - "label.selecttags": "Tags auswählen...", "label.subscribe": "Abonnieren", "label.tags": "Tags", - "label.unfollow": "Entfolgen", "label.unread": "Ungelesen", "label.unsubscribe": "Abbestellen", "label.voters": "Wähler", "labels.notagsavailable": "Keine Tags verfügbar", - "labels.notagsselected": "Keine Tags ausgewählt", "legal.agreement": "Ich habe die <0/> und <1/> gelesen und akzeptiere sie.", - "legal.notice": "Mit der Anmeldung stimmst du den <2/><0/> und <1/> zu.", + "legal.notice": "Mit der Anmeldung stimmst du den <0/><1/> und <2/> zu.", "legal.privacypolicy": "Datenschutzerklärung", "legal.termsofservice": "Nutzungsbedingungen", "linkmodal.insert": "Link einfügen", "linkmodal.text.label": "Anzuzeigender Text", "linkmodal.text.placeholder": "Linktext eingeben", - "linkmodal.text.required": "Text ist erforderlich", "linkmodal.title": "Link einfügen", - "linkmodal.url.invalid": "Bitte geben Sie eine gültige URL ein", "linkmodal.url.label": "", "linkmodal.url.placeholder": "", - "linkmodal.url.required": "URL ist erforderlich", "menu.administration": "Verwaltung", "menu.mysettings": "Meine Einstellungen", "menu.signout": "Abmelden", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "Keine Benutzer gefunden, die <0>{0} entsprechen.", "modal.showvotes.query.placeholder": "Suche nach Benutzern nach Namen...", "modal.signin.header": "Reiche dein Feedback ein", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "Lies, was in den letzten 30 Tagen geschrieben wurde.", "mynotifications.message.nounread": "Keine ungelesenen Benachrichtigungen.", "mynotifications.page.subtitle": "Bleibe immer auf dem Laufenden", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "E-Mail", "mysettings.notification.channelweb": "Web", "mysettings.notification.event.discussion": "Diskussion", - "mysettings.notification.event.discussion.staff": "kommentare zu allen Beiträgen, es sei denn, sie werden individuell deabonniert", - "mysettings.notification.event.discussion.visitors": "Kommentare zu Beiträgen, die du abonniert hast", "mysettings.notification.event.mention": "Erwähnungen", "mysettings.notification.event.newpost": "Neuer Beitrag", - "mysettings.notification.event.newpost.staff": "neue Beiträge auf dieser Seite", - "mysettings.notification.event.newpost.visitors": "neue Beiträge auf dieser Seite", "mysettings.notification.event.newpostcreated": "Deine Idee wurde hinzugefügt 👍", "mysettings.notification.event.statuschanged": "Status geändert", - "mysettings.notification.event.statuschanged.staff": "Status bei allen Beiträgen ändern, es sei denn, sie wurden einzeln abgemeldet", - "mysettings.notification.event.statuschanged.visitors": "Status Änderungen bei Beiträgen, die du abonniert hast", - "mysettings.notification.message.emailonly": "Du wirst <0>E-Mail Benachrichtigungen über {about} erhalten.", - "mysettings.notification.message.none": "Du wirst <0>KEINE Benachrichtigungen über dieses Ereignis erhalten.", - "mysettings.notification.message.webandemail": "Du wirst <0>Web und <0>E-Mail Benachrichtigungen über {about} erhalten.", - "mysettings.notification.message.webonly": "Du wirst <0>web Benachrichtigungen über {about} erhalten.", "mysettings.notification.title": "Folgendes Panel verwenden, um zu wählen, für welche Ereignisse du Benachrichtigungen erhalten möchtest", "mysettings.page.subtitle": "Profileinstellungen verwalten", "mysettings.page.title": "Einstellungen", - "newpost.modal.addimage": "Bilder hinzufügen", "newpost.modal.description.placeholder": "Erzähl uns von deiner Idee. Erkläre sie ausführlich, halte dich nicht zurück, je mehr Informationen, umso besser.", "newpost.modal.submit": "Reiche deine Idee ein", "newpost.modal.title": "Teile deine Idee ...", "newpost.modal.title.label": "Gib deiner Idee einen Titel", "newpost.modal.title.placeholder": "Eine kurze und knappe Zusammenfassung in wenigen Worten", "page.backhome": "Zurück zur <0>{0} Homepage.", - "page.notinvited.text": "Wir konnten kein Account für deine E-Mail-Adresse finden.", - "page.notinvited.title": "Nicht eingeladen", "page.pendingactivation.didntreceive": "Haben Sie die E-Mail nicht erhalten?", "page.pendingactivation.resend": "Bestätigungs-E-Mail erneut senden", "page.pendingactivation.resending": "Erneut gesendet...", "page.pendingactivation.text": "Wir haben Ihnen eine Bestätigungs-E-Mail mit einem Link zur Aktivierung Ihrer Website geschickt.", "page.pendingactivation.text2": "Bitte überprüfe deinen Posteingang, um ihn zu aktivieren.", "page.pendingactivation.title": "Dein Account ist nicht aktiviert", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "Kommentar-Link konnte nicht kopiert werden, bitte URL der Webseite kopieren", "showpost.comment.copylink.success": "Kommentar-Link in die Zwischenablage kopiert", "showpost.comment.unknownhighlighted": "Ungültige Kommentar ID #{id}", "showpost.commentinput.placeholder": "Kommentar hinzufügen", "showpost.copylink.success": "Link in die Zwischenablage kopiert", - "showpost.discussionpanel.emptymessage": "Niemand hat bisher kommentiert.", - "showpost.label.author": "Gepostet von <0/> · <1/>", "showpost.message.nodescription": "Keine Beschreibung angegeben.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "Diese Aktion <0>kann nicht rückgängig gemacht werden.", "showpost.moderationpanel.text.placeholder": "Warum löschst du diesen Beitrag? (optional)", - "showpost.mostwanted.comments": "{count, plural, one {# Kommentar} other {# Kommentare}}", - "showpost.mostwanted.votes": "{count, plural, one {# Stimme} other {# Stimmen}}", "showpost.notificationspanel.message.subscribed": "Du erhältst Benachrichtigungen über Aktivitäten auf diesem Beitrag.", "showpost.notificationspanel.message.unsubscribed": "Du erhältst keine Benachrichtigungen über diesen Beitrag.", "showpost.postsearch.numofvotes": "{0} Stimmen", "showpost.postsearch.query.placeholder": "Originalbeitrag suchen...", - "showpost.response.date": "Status geändert zu {status} am {statusDate}", "showpost.responseform.message.mergedvotes": "Stimmen aus diesem Beitrag werden mit den Stimmen vom ursprünglichen Beitrag zusammengeführt.", "showpost.responseform.text.placeholder": "Was passiert in diesem Beitrag? Lass deine Benutzer wissen, was deine Pläne sind...", "showpost.votespanel.more": "+{extraVotesCount} mehr", @@ -208,7 +216,6 @@ "signin.code.instruction": "Bitte geben Sie den soeben gesendeten Code an <0>{email} ein.", "signin.code.placeholder": "Geben Sie hier den Code ein.", "signin.code.sent": "Ein neuer Code wurde an Ihre E-Mail-Adresse gesendet.", - "signin.code.submit": "Einreichen", "signin.email.placeholder": "E-Mail-Adresse", "signin.message.email": "Mit E-Mail fortfahren", "signin.message.emaildisabled": "Die E-Mail-Authentifizierung wurde von einem Administrator deaktiviert. Wenn du ein Administrator-Konto hast und diese Einschränkung umgehen musst, klicke bitte <0>hier.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "Einloggen mit", "signin.name.placeholder": "Ihr Name", "validation.custom.maxattachments": "Es sind maximal {number} Anhänge zulässig.", - "validation.custom.maximagesize": "Die Bildgröße muss kleiner als {kilobytes}KB sein.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# Tag} other {# Tags}}", - "signin.code.expired": "Dieser Code ist abgelaufen oder wurde bereits verwendet. Bitte fordern Sie einen neuen an.", - "signin.code.invalid": "Der eingegebene Code ist ungültig. Bitte versuchen Sie es erneut." + "validation.custom.maximagesize": "Die Bildgröße muss kleiner als {kilobytes}KB sein." } \ No newline at end of file diff --git a/locale/el/client.json b/locale/el/client.json index ceb1a4836..3d8f2e6a7 100644 --- a/locale/el/client.json +++ b/locale/el/client.json @@ -4,13 +4,15 @@ "action.close": "Κλείσιμο", "action.commentsfeed": "Ροή σχολίων", "action.confirm": "Επιβεβαίωση", - "action.continue": "Συνεχίζω", "action.copylink": "Αντιγραφή συνδέσμου", "action.delete": "Διαγραφή", + "action.delete.block": "", "action.edit": "Επεξεργασία", "action.markallasread": "Σήμανση όλων ως αναγνωσμένων", "action.ok": "ΟΚ", "action.postsfeed": "Ροή αναρτήσεων", + "action.publish": "", + "action.publish.verify": "", "action.respond": "Απάντηση", "action.save": "Αποθήκευση", "action.signin": "Είσοδος", @@ -18,7 +20,6 @@ "action.submit": "Υποβολή", "action.vote": "Ψηφίστε αυτήν την ιδέα", "action.voted": "Ψηφίστηκε!", - "d41FkJ": "{count, plural, one {# tag} other {# tags}}", "editor.markdownmode": "Μετάβαση στο πρόγραμμα επεξεργασίας markdown", "editor.richtextmode": "Μετάβαση σε πρόγραμμα επεξεργασίας εμπλουτισμένου κειμένου", "enum.poststatus.completed": "Ολοκληρωμένο", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "Θα θέλαμε πολύ να ακούσουμε τι σκέφτεστε.\n\nΤι μπορούμε να κάνουμε καλύτερα; Αυτό είναι το μέρος για να ψηφίσετε, να συζητήσετε και να μοιραστείτε ιδέες.", "home.lonely.suggestion": "Συνιστάται να δημιουργείσετε εδώ <0>τουλάχιστον 3 προτάσεις πριν από την κοινή χρήση αυτής της ιστοσελίδας. Το αρχικό περιεχόμενο είναι σημαντικό να αρχίσετε να προσελκύετε το κοινό σας.", "home.lonely.text": "Δεν έχουν δημιουργηθεί αναρτήσεις ακόμα.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "Ιδιος", "home.postfilter.label.status": "Κατάσταση", - "home.postfilter.label.view": "Προβολή", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "Πιο συζητημένα", "home.postfilter.option.mostwanted": "Πιο ενδιαφέροντα", "home.postfilter.option.myposts": "Οι αναρτήσεις μου", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "Χωρίς ετικέτα", "home.postfilter.option.recent": "Πρόσφατα", "home.postfilter.option.trending": "Δημοφιλή", - "home.postinput.description.placeholder": "Περιγράψτε την πρότασή σας (προαιρετικό)", "home.postscontainer.label.noresults": "Δεν υπάρχουν αποτελέσματα που να ταιριάζουν με την αναζήτηση σας, δοκιμάστε κάτι διαφορετικό.", "home.postscontainer.label.viewmore": "Δείτε περισσότερες δημοσιεύσεις", "home.postscontainer.query.placeholder": "Αναζήτηση", "home.postsort.label": "Ταξινόμηση κατά:", - "home.similar.subtitle": "Εξετάστε το ενδεχόμενο ψηφοφορίας για υπάρχουσες προτάσεις.", "home.similar.title": "Παρόμοιες προτάσεις", - "home.tagsfilter.label.with": "με", - "home.tagsfilter.selected.none": "Οποιαδήποτε ετικέτα", - "label.actions": "Ενέργειες", "label.addtags": "Προσθήκη ετικετών...", "label.avatar": "Φωτογραφία προφίλ", "label.custom": "Προσαρμοσμένη", - "label.description": "Περιγραφή", "label.discussion": "Συζήτηση", "label.edittags": "Επεξεργασία ετικετών", "label.email": "E-mail", @@ -77,36 +74,27 @@ "label.following": "Εξής", "label.gravatar": "Γκράβαταρ", "label.letter": "Γράμμα", - "label.moderation": "Εποπτεία", "label.name": "Όνομα", "label.none": "Κανένα", - "label.notagsavailable": "Δεν υπάρχουν διαθέσιμες ετικέτες", - "label.notagsselected": "Δεν έχουν επιλεγεί ετικέτες", "label.notifications": "Ειδοποιήσεις", "label.or": "Ή", "label.searchtags": "Αναζήτηση ετικετών...", - "label.selecttags": "Επιλογή ετικετών...", "label.subscribe": "Εγγραφείτε", "label.tags": "Ετικέτες", - "label.unfollow": "Κατάργηση παρακολούθησης", "label.unread": "Αδιάβαστο", "label.unsubscribe": "Απεγγραφή", "label.voters": "Ψηφοφόροι", "labels.notagsavailable": "Δεν υπάρχουν διαθέσιμες ετικέτες", - "labels.notagsselected": "Δεν έχουν επιλεγεί ετικέτες", "legal.agreement": "Έχω διαβάσει και συμφωνώ με το <0/> και <1/>.", - "legal.notice": "Με την είσοδο, συμφωνείτε με το <2/><0/> και <1/>.", + "legal.notice": "Με την είσοδο, συμφωνείτε με το <0/><1/> και <2/>.", "legal.privacypolicy": "Πολιτική Απορρήτου", "legal.termsofservice": "Όροι χρήσης", "linkmodal.insert": "Εισαγωγή συνδέσμου", "linkmodal.text.label": "Κείμενο προς εμφάνιση", "linkmodal.text.placeholder": "Εισαγάγετε κείμενο συνδέσμου", - "linkmodal.text.required": "Απαιτείται κείμενο", "linkmodal.title": "Εισαγωγή συνδέσμου", - "linkmodal.url.invalid": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση URL", "linkmodal.url.label": "", "linkmodal.url.placeholder": "", - "linkmodal.url.required": "Απαιτείται διεύθυνση URL", "menu.administration": "Διαχείριση", "menu.mysettings": "Οι Ρυθμίσεις Μου", "menu.signout": "Αποσύνδεση", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "Δεν βρέθηκαν χρήστες που να ταιριάζουν <0>{0}.", "modal.showvotes.query.placeholder": "Αναζήτηση χρηστών με όνομα...", "modal.signin.header": "Υποβάλετε τα σχόλιά σας", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "Διαβάστε τις τελευταίες 30 ημέρες.", "mynotifications.message.nounread": "Καμία μη αναγνωσμένη ειδοποίηση.", "mynotifications.page.subtitle": "Μείνετε ενημερωμένοι με το τι συμβαίνει", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "E-mail", "mysettings.notification.channelweb": "Ιστοσελίδα", "mysettings.notification.event.discussion": "Συζήτηση", - "mysettings.notification.event.discussion.staff": "σχόλια σε όλες τις δημοσιεύσεις εκτός αν διαγραφούν μεμονωμένα", - "mysettings.notification.event.discussion.visitors": "σχόλια σχετικά με δημοσιεύσεις στις οποίες έχετε εγγραφεί", "mysettings.notification.event.mention": "Αναφορές", "mysettings.notification.event.newpost": "Νέα Δημοσίευση", - "mysettings.notification.event.newpost.staff": "νέες δημοσιεύσεις σε αυτήν την ιστοσελίδα", - "mysettings.notification.event.newpost.visitors": "νέες δημοσιεύσεις σε αυτήν την ιστοσελίδα", "mysettings.notification.event.newpostcreated": "Η ιδέα σου προστέθηκε 👍", "mysettings.notification.event.statuschanged": "Η Κατάσταση Άλλαξε", - "mysettings.notification.event.statuschanged.staff": "αλλαγή κατάστασης σε όλες τις δημοσιεύσεις εκτός αν διαγραφούν μεμονωμένα", - "mysettings.notification.event.statuschanged.visitors": "αλλαγή κατάστασης στις αναρτήσεις στις οποίες έχετε εγγραφεί", - "mysettings.notification.message.emailonly": "Θα λάβετε <0>email για το {about}.", - "mysettings.notification.message.none": "<0>ΔΕΝ Θα λάβετε οποιαδήποτε ειδοποίηση σχετικά με αυτό το γεγονός.", - "mysettings.notification.message.webandemail": "Θα λάβετε ειδοποιήσεις <0>web και <1>email για {about}.", - "mysettings.notification.message.webonly": "Θα λάβετε ειδοποιήσεις <0>web για {about}.", "mysettings.notification.title": "Χρησιμοποιήστε τον παρακάτω πίνακα για να επιλέξετε για ποια γεγονότα θα θέλατε να λαμβάνετε ειδοποίηση", "mysettings.page.subtitle": "Διαχείριση των ρυθμίσεων του προφίλ σας", "mysettings.page.title": "Ρυθμίσεις", - "newpost.modal.addimage": "Προσθήκη εικόνων", "newpost.modal.description.placeholder": "Πείτε μας γι' αυτό. Εξηγήστε το πλήρως, μην διστάζετε, όσο περισσότερες πληροφορίες τόσο το καλύτερο.", "newpost.modal.submit": "Υποβάλετε την ιδέα σας", "newpost.modal.title": "Μοιραστείτε την ιδέα σας...", "newpost.modal.title.label": "Δώστε έναν τίτλο στην ιδέα σας", "newpost.modal.title.placeholder": "Κάτι σύντομο και περιεκτικό, συνοψίστε το σε λίγες λέξεις", "page.backhome": "Επιστρέψτε με στην αρχική σελίδα <0>{0}.", - "page.notinvited.text": "Δεν μπορέσαμε να βρούμε λογαριασμό για τη διεύθυνση email σας.", - "page.notinvited.title": "Δεν έχει προσκληθεί", "page.pendingactivation.didntreceive": "Δεν λάβατε το email;", "page.pendingactivation.resend": "Επανάληψη αποστολής email επαλήθευσης", "page.pendingactivation.resending": "Επανάληψη αποστολής...", "page.pendingactivation.text": "Σας στείλαμε ένα email επιβεβαίωσης με ένα σύνδεσμο για να ενεργοποιήσετε τον ιστότοπό σας.", "page.pendingactivation.text2": "Παρακαλώ ελέγξτε τα εισερχόμενά σας για να το ενεργοποιήσετε.", "page.pendingactivation.title": "Ο λογαριασμός σας εκκρεμεί ενεργοποίηση", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "Η αντιγραφή του συνδέσμου σχολίου απέτυχε. Παρακαλώ αντιγράψτε τη διεύθυνση URL της σελίδας.", "showpost.comment.copylink.success": "Ο σύνδεσμος σχολίου αντιγράφηκε στο πρόχειρο", "showpost.comment.unknownhighlighted": "Μη έγκυρο αναγνωριστικό σχολίου #{id}", "showpost.commentinput.placeholder": "Αφήστε ένα σχόλιο", "showpost.copylink.success": "Ο σύνδεσμος αντιγράφηκε στο πρόχειρο", - "showpost.discussionpanel.emptymessage": "Κανείς δεν έχει σχολιάσει ακόμα.", - "showpost.label.author": "Δημοσιεύτηκε από <0/> · <1/>", "showpost.message.nodescription": "Δεν υπάρχει περιγραφή.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "Αυτή η λειτουργία <0>δεν μπορεί να αναιρεθεί.", "showpost.moderationpanel.text.placeholder": "Γιατί διαγράφετε αυτή την ανάρτηση; (προαιρετικό)", - "showpost.mostwanted.comments": "{count, plural, one {# comment} other {# comments}}", - "showpost.mostwanted.votes": "{count, plural, one {# votes} other {# votes}}", "showpost.notificationspanel.message.subscribed": "Λαμβάνετε ειδοποιήσεις σχετικά με δραστηριότητα σε αυτή τη δημοσίευση.", "showpost.notificationspanel.message.unsubscribed": "Δεν θα λάβετε καμία ειδοποίηση σχετικά με αυτή τη δημοσίευση.", "showpost.postsearch.numofvotes": "{0} Ψήφοι", "showpost.postsearch.query.placeholder": "Αναζήτηση αρχικής ανάρτησης...", - "showpost.response.date": "Η κατάσταση άλλαξε σε {status} στις {statusDate}", "showpost.responseform.message.mergedvotes": "Οι ψήφοι από αυτό το post θα συγχωνευτούν στο αρχικό post.", "showpost.responseform.text.placeholder": "Τι συμβαίνει με αυτή την ανάρτηση; Αφήστε τους χρήστες σας να γνωρίζουν ποια είναι τα σχέδιά σας...", "showpost.votespanel.more": "+{extraVotesCount} περισσότερα", @@ -208,7 +216,6 @@ "signin.code.instruction": "Παρακαλώ πληκτρολογήστε τον κωδικό που μόλις στείλαμε στη διεύθυνση <0>{email}", "signin.code.placeholder": "Πληκτρολογήστε τον κωδικό εδώ", "signin.code.sent": "Ένας νέος κωδικός έχει σταλεί στο email σας.", - "signin.code.submit": "Υποτάσσομαι", "signin.email.placeholder": "Διεύθυνση ηλεκτρονικού ταχυδρομείου", "signin.message.email": "Συνέχεια με email", "signin.message.emaildisabled": "Ο έλεγχος ταυτότητας email έχει απενεργοποιηθεί από τον διαχειριστή. Εάν έχετε λογαριασμό διαχειριστή και πρέπει να παρακάμψετε αυτόν τον περιορισμό, παρακαλώ <0>κάντε κλικ εδώ.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "Συνδεθείτε με", "signin.name.placeholder": "Το όνομά σας", "validation.custom.maxattachments": "Επιτρέπονται έως {number} συνημμένα.", - "validation.custom.maximagesize": "Το μέγεθος της εικόνας πρέπει να είναι μικρότερο από {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# ετικέτα} other {# ετικέτες}}", - "signin.code.expired": "Αυτός ο κωδικός έχει λήξει ή έχει ήδη χρησιμοποιηθεί. Παρακαλούμε ζητήστε έναν νέο.", - "signin.code.invalid": "Ο κωδικός που εισαγάγατε δεν είναι έγκυρος. Δοκιμάστε ξανά." + "validation.custom.maximagesize": "Το μέγεθος της εικόνας πρέπει να είναι μικρότερο από {kilobytes}KB." } \ No newline at end of file diff --git a/locale/en/client.json b/locale/en/client.json index 9fecb8f4b..189e14aab 100644 --- a/locale/en/client.json +++ b/locale/en/client.json @@ -4,13 +4,15 @@ "action.close": "Close", "action.commentsfeed": "Comment Feed", "action.confirm": "Confirm", - "action.continue": "Continue", "action.copylink": "Copy link", "action.delete": "Delete", + "action.delete.block": "Delete & Block", "action.edit": "Edit", "action.markallasread": "Mark All as Read", "action.ok": "OK", "action.postsfeed": "Posts Feed", + "action.publish": "Publish", + "action.publish.verify": "Publish & Trust", "action.respond": "Respond", "action.save": "Save", "action.signin": "Sign in", @@ -18,7 +20,6 @@ "action.submit": "Submit", "action.vote": "Vote for this idea", "action.voted": "Voted!", - "d41FkJ": "{count, plural, one {# tag} other {# tags}}", "editor.markdownmode": "Switch to markdown editor", "editor.richtextmode": "Switch to rich text editor", "enum.poststatus.completed": "Completed", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "We'd love to hear what you're thinking about.\n\nWhat can we do better? This is the place for you to vote, discuss and share ideas.", "home.lonely.suggestion": "It's recommended that you create <0>at least 3 suggestions here before sharing this site. The initial content is important to start engaging your audience.", "home.lonely.text": "No posts have been created yet.", - "home.postfilter.label.myactivity": "Own", + "home.postfilter.label.moderation": "Moderation", + "home.postfilter.label.myactivity": "My activity", "home.postfilter.label.status": "Status", - "home.postfilter.label.view": "View", + "home.postfilter.moderation.approved": "Approved", + "home.postfilter.moderation.pending": "Pending", "home.postfilter.option.mostdiscussed": "Most Discussed", "home.postfilter.option.mostwanted": "Most Wanted", "home.postfilter.option.myposts": "My Posts", @@ -56,19 +59,14 @@ "home.postfilter.option.notags": "Untagged", "home.postfilter.option.recent": "Recent", "home.postfilter.option.trending": "Trending", - "home.postinput.description.placeholder": "Describe your suggestion (optional)", "home.postscontainer.label.noresults": "No results matched your search, try something different.", "home.postscontainer.label.viewmore": "View more posts", "home.postscontainer.query.placeholder": "Search", "home.postsort.label": "Sort by:", "home.similar.title": "We have similar posts, is your idea already on the list?", - "home.tagsfilter.label.with": "with", - "home.tagsfilter.selected.none": "Any tag", - "label.actions": "Actions", "label.addtags": "Add tags...", "label.avatar": "Avatar", "label.custom": "Custom", - "label.description": "Description", "label.discussion": "Discussion", "label.edittags": "Edit tags", "label.email": "Email", @@ -76,36 +74,27 @@ "label.following": "Following", "label.gravatar": "Gravatar", "label.letter": "Letter", - "label.moderation": "Moderation", "label.name": "Name", "label.none": "None", - "label.notagsavailable": "No tags available", - "label.notagsselected": "No tags selected", "label.notifications": "Notifications", "label.or": "OR", "label.searchtags": "Search tags...", - "label.selecttags": "Select tags...", "label.subscribe": "Subscribe", "label.tags": "Tags", - "label.unfollow": "Unfollow", "label.unread": "Unread", "label.unsubscribe": "Unsubscribe", "label.voters": "Voters", "labels.notagsavailable": "No tags available", - "labels.notagsselected": "No tags selected", "legal.agreement": "I have read and agree to the <0/> and <1/>.", - "legal.notice": "By signing in, you agree to the <2/><0/> and <1/>.", + "legal.notice": "By signing in, you agree to the <0/><1/> and <2/>.", "legal.privacypolicy": "Privacy Policy", "legal.termsofservice": "Terms of Service", "linkmodal.insert": "Insert Link", "linkmodal.text.label": "Text to display", "linkmodal.text.placeholder": "Enter link text", - "linkmodal.text.required": "Text is required", "linkmodal.title": "Insert Link", - "linkmodal.url.invalid": "Please enter a valid URL", "linkmodal.url.label": "URL", "linkmodal.url.placeholder": "https://example.com", - "linkmodal.url.required": "URL is required", "menu.administration": "Administration", "menu.mysettings": "My Settings", "menu.signout": "Sign out", @@ -127,6 +116,26 @@ "modal.showvotes.message.zeromatches": "No users found matching <0>{query}.", "modal.showvotes.query.placeholder": "Search for users by name...", "modal.signin.header": "Join the conversation", + "moderation.comment.delete.block.error": "Failed to delete comment and block user", + "moderation.comment.delete.error": "Failed to delete comment", + "moderation.comment.deleted": "Comment deleted successfully", + "moderation.comment.deleted.blocked": "Comment deleted and user blocked", + "moderation.comment.publish.error": "Failed to publish comment", + "moderation.comment.publish.verify.error": "Failed to publish comment and verify user", + "moderation.comment.published": "Comment published successfully", + "moderation.comment.published.verified": "Comment published and user verified", + "moderation.empty": "All content has been moderated. You're all caught up!", + "moderation.fetch.error": "Failed to fetch moderation items", + "moderation.post.delete.block.error": "Failed to delete post and block user", + "moderation.post.delete.error": "Failed to delete post", + "moderation.post.deleted": "Post deleted successfully", + "moderation.post.deleted.blocked": "Post deleted and user blocked", + "moderation.post.publish.error": "Failed to publish post", + "moderation.post.publish.verify.error": "Failed to publish post and verify user", + "moderation.post.published": "Post published successfully", + "moderation.post.published.verified": "Post published and user verified", + "moderation.subtitle": "These ideas and comments are from people outside of your trusted users list, you decide if they get published.", + "moderation.title": "Moderation Queue", "mynotifications.label.readrecently": "Read on last 30 days.", "mynotifications.message.nounread": "No unread notifications.", "mynotifications.page.subtitle": "Stay up to date with what's happening", @@ -149,67 +158,64 @@ "mysettings.notification.channelemail": "Email", "mysettings.notification.channelweb": "Web", "mysettings.notification.event.discussion": "New Comments", - "mysettings.notification.event.discussion.staff": "comments on all posts unless individually unsubscribed", - "mysettings.notification.event.discussion.visitors": "comments on posts you've subscribed to", "mysettings.notification.event.mention": "Mentions", "mysettings.notification.event.newpost": "New Post", - "mysettings.notification.event.newpost.staff": "new posts on this site", - "mysettings.notification.event.newpost.visitors": "new posts on this site", "mysettings.notification.event.newpostcreated": "Your idea has been added 👍", "mysettings.notification.event.statuschanged": "Status Changed", - "mysettings.notification.event.statuschanged.staff": "status change on all posts unless individually unsubscribed", - "mysettings.notification.event.statuschanged.visitors": "status change on posts you've subscribed to", - "mysettings.notification.message.emailonly": "You'll receive <0>email notifications about {about}.", - "mysettings.notification.message.none": "You'll <0>NOT receive any notification about this event.", - "mysettings.notification.message.webandemail": "You'll receive <0>web and <1>email notifications about {about}.", - "mysettings.notification.message.webonly": "You'll receive <0>web notifications about {about}.", "mysettings.notification.title": "Choose the events to receive a notification for.", "mysettings.page.subtitle": "Manage your profile settings", "mysettings.page.title": "Settings", - "newpost.modal.addimage": "Add Images", "newpost.modal.description.placeholder": "Tell us about it. Explain it fully, don't hold back, the more information the better.", "newpost.modal.submit": "Submit your idea", "newpost.modal.title": "Share your idea...", "newpost.modal.title.label": "Give your idea a title", "newpost.modal.title.placeholder": "Something short and snappy, sum it up in a few words", "page.backhome": "Take me back to <0>{0} home page.", - "page.notinvited.text": "We could not find an account for your email address.", - "page.notinvited.title": "Not invited", "page.pendingactivation.didntreceive": "Didn't receive the email?", "page.pendingactivation.resend": "Resend verification email", "page.pendingactivation.resending": "Resending...", "page.pendingactivation.text": "We sent you a confirmation email with a link to activate your site.", "page.pendingactivation.text2": "Please check your inbox to activate it.", "page.pendingactivation.title": "Your account is pending activation", + "pagination.next": "Next", + "pagination.prev": "Previous", + "post.pending": "pending", "showpost.comment.copylink.error": "Could not copy comment link, please copy page URL", "showpost.comment.copylink.success": "Successfully copied comment link to clipboard", "showpost.comment.unknownhighlighted": "Unknown comment ID #{id}", "showpost.commentinput.placeholder": "Leave a comment", "showpost.copylink.success": "Link copied to clipboard", - "showpost.discussionpanel.emptymessage": "No one has commented yet.", - "showpost.label.author": "Posted by <0/> · <1/>", "showpost.message.nodescription": "No description provided.", + "showpost.moderation.admin.description": "This idea needs your approval before being published", + "showpost.moderation.admin.title": "Moderation", + "showpost.moderation.approved": "Post approved successfully", + "showpost.moderation.approveerror": "Failed to approve post", + "showpost.moderation.awaiting": "Awaiting moderation.", + "showpost.moderation.comment.admin.description": "This comment needs your approval before being published", + "showpost.moderation.comment.approved": "Comment approved successfully", + "showpost.moderation.comment.approveerror": "Failed to approve comment", + "showpost.moderation.comment.awaiting": "Awaiting moderation.", + "showpost.moderation.comment.declined": "Comment declined successfully", + "showpost.moderation.comment.declineerror": "Failed to decline comment", + "showpost.moderation.commentsuccess": "Your comment has been submitted and is awaiting moderation 📝", + "showpost.moderation.declined": "Post declined successfully", + "showpost.moderation.declineerror": "Failed to decline post", + "showpost.moderation.postsuccess": "Your idea has been submitted and is awaiting moderation 📝", "showpost.moderationpanel.text.help": "This operation <0>cannot be undone.", "showpost.moderationpanel.text.placeholder": "Why are you deleting this post? (optional)", - "showpost.mostwanted.comments": "{count, plural, one {# comment} other {# comments}}", - "showpost.mostwanted.votes": "{count, plural, one {# votes} other {# votes}}", "showpost.notificationspanel.message.subscribed": "You’re receiving notifications about activity on this post.", "showpost.notificationspanel.message.unsubscribed": "You'll not receive any notification about this post.", "showpost.postsearch.numofvotes": "{0} votes", "showpost.postsearch.query.placeholder": "Search original post...", - "showpost.response.date": "Status changed to {status} on {statusDate}", "showpost.responseform.message.mergedvotes": "Votes from this post will be merged into original post.", "showpost.responseform.text.placeholder": "What's going on with this post? Let your users know what are your plans...", "showpost.votespanel.more": "+{extraVotesCount} more", "showpost.votespanel.seedetails": "see details", "signin.code.edit": "Edit", - "signin.code.expired": "This code has expired or already been used. Please request a new one.", "signin.code.getnew": "Get a new code", "signin.code.instruction": "Please type in the code we just sent to <0>{email}", - "signin.code.invalid": "The code you entered is invalid. Please try again.", "signin.code.placeholder": "Type in the code here", "signin.code.sent": "A new code has been sent to your email.", - "signin.code.submit": "Submit", "signin.email.placeholder": "Email address", "signin.message.email": "Continue with Email", "signin.message.emaildisabled": "Email authentication has been disabled by an administrator. If you have an administrator account and need to bypass this restriction, please <0>click here.", @@ -222,6 +228,5 @@ "signin.message.socialbutton.intro": "Continue with", "signin.name.placeholder": "Your name", "validation.custom.maxattachments": "A maximum of {number} attachments are allowed.", - "validation.custom.maximagesize": "The image size must be smaller than {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}" + "validation.custom.maximagesize": "The image size must be smaller than {kilobytes}KB." } diff --git a/locale/es-ES/client.json b/locale/es-ES/client.json index 2e3c31f16..912f99b12 100644 --- a/locale/es-ES/client.json +++ b/locale/es-ES/client.json @@ -4,13 +4,15 @@ "action.close": "Cerrar", "action.commentsfeed": "Feed de comentarios", "action.confirm": "Confirmar", - "action.continue": "Continuar", "action.copylink": "Copiar enlace", "action.delete": "Eliminar", + "action.delete.block": "", "action.edit": "Editar", "action.markallasread": "Marcar Todo como Leído", "action.ok": "Aceptar", "action.postsfeed": "Feed de publicaciones", + "action.publish": "", + "action.publish.verify": "", "action.respond": "Responder", "action.save": "Guardar", "action.signin": "Iniciar sesión", @@ -18,7 +20,6 @@ "action.submit": "Enviar", "action.vote": "Vota por esta idea", "action.voted": "¡Votado!", - "d41FkJ": "{count, plural, one {# tag} other {# tags}}", "editor.markdownmode": "Cambiar al editor de rebajas", "editor.richtextmode": "Cambiar al editor de texto enriquecido", "enum.poststatus.completed": "Completado", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "Nos encantaría escuchar lo que estás pensando.\n\n¿Qué podemos hacer mejor? Este es el lugar para que votes, discutas y compartas ideas.", "home.lonely.suggestion": "Se recomienda que cree <0>al menos 3 sugerencias aquí antes de compartir este sitio. El contenido inicial es importante para empezar a atraer a su audiencia.", "home.lonely.text": "No se han creado publicaciones todavía.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "Propio", "home.postfilter.label.status": "Estado", - "home.postfilter.label.view": "Vista", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "Más Discutidos", "home.postfilter.option.mostwanted": "Más Deseados", "home.postfilter.option.myposts": "Mis publicaciones", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "Sin etiquetar", "home.postfilter.option.recent": "Reciente", "home.postfilter.option.trending": "Populares", - "home.postinput.description.placeholder": "Describe tu sugerencia (opcional)", "home.postscontainer.label.noresults": "No hay resultados que coincidan con tu búsqueda, prueba algo diferente.", "home.postscontainer.label.viewmore": "Ver más publicaciones", "home.postscontainer.query.placeholder": "Buscar", "home.postsort.label": "Ordenar por:", - "home.similar.subtitle": "Considere votar en publicaciones existentes.", "home.similar.title": "Publicaciones similares", - "home.tagsfilter.label.with": "con", - "home.tagsfilter.selected.none": "Cualquier etiqueta", - "label.actions": "Acciones", "label.addtags": "Añadir etiquetas...", "label.avatar": "Avatar", "label.custom": "Personalizado", - "label.description": "Descripción", "label.discussion": "Discusión", "label.edittags": "Editar etiquetas", "label.email": "Correo electrónico", @@ -77,36 +74,27 @@ "label.following": "Siguiente", "label.gravatar": "Gravatar", "label.letter": "Letras", - "label.moderation": "Moderación", "label.name": "Nombre", "label.none": "Ninguno", - "label.notagsavailable": "No hay etiquetas disponibles", - "label.notagsselected": "No hay etiquetas seleccionadas", "label.notifications": "Notificaciones", "label.or": "O", "label.searchtags": "Buscar etiquetas...", - "label.selecttags": "Seleccionar etiquetas...", "label.subscribe": "Suscribete", "label.tags": "Etiquetas", - "label.unfollow": "Dejar de seguir", "label.unread": "Sin leer", "label.unsubscribe": "Cancelar la suscripción", "label.voters": "Votantes", "labels.notagsavailable": "No hay etiquetas disponibles", - "labels.notagsselected": "No hay etiquetas seleccionadas", "legal.agreement": "He leído y acepto los <0/> y <1/>.", - "legal.notice": "Al iniciar sesión, aceptas los <2/><0/> y <1/>.", + "legal.notice": "Al iniciar sesión, aceptas los <0/><1/> y <2/>.", "legal.privacypolicy": "Política de Privacidad", "legal.termsofservice": "Términos del servicio", "linkmodal.insert": "Insertar un enlace", "linkmodal.text.label": "Texto a mostrar", "linkmodal.text.placeholder": "Ingresa el texto del enlace", - "linkmodal.text.required": "El texto es requerido", "linkmodal.title": "Insertar un enlace", - "linkmodal.url.invalid": "Por favor ingresa una URL válida", "linkmodal.url.label": "URL", "linkmodal.url.placeholder": "https://example.com", - "linkmodal.url.required": "La URL es requerida", "menu.administration": "Administración", "menu.mysettings": "Mis ajustes", "menu.signout": "Cerrar sesión", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "No se encontraron usuarios que coincidan con <0>{0}.", "modal.showvotes.query.placeholder": "Buscar usuarios por nombre...", "modal.signin.header": "Envíe sus comentarios", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "Leer los últimos 30 días.", "mynotifications.message.nounread": "No hay notificaciones no leídas.", "mynotifications.page.subtitle": "Mantente informado de lo que está sucediendo", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "Correo electrónico", "mysettings.notification.channelweb": "Web", "mysettings.notification.event.discussion": "Discusión", - "mysettings.notification.event.discussion.staff": "comentarios en todas las publicaciones a menos que se cancele la suscripción individualmente", - "mysettings.notification.event.discussion.visitors": "comentarios en publicaciones a las que te has suscrito", "mysettings.notification.event.mention": "Menciones", "mysettings.notification.event.newpost": "Nueva Publicación", - "mysettings.notification.event.newpost.staff": "nuevas publicaciones en este sitio", - "mysettings.notification.event.newpost.visitors": "nuevas publicaciones en este sitio", "mysettings.notification.event.newpostcreated": "Tu idea ha sido añadida 👍", "mysettings.notification.event.statuschanged": "Estado Modificado", - "mysettings.notification.event.statuschanged.staff": "cambio de estado en todas las publicaciones a menos que se cancele la suscripción individualmente", - "mysettings.notification.event.statuschanged.visitors": "cambio de estado en las publicaciones a las que te has suscrito", - "mysettings.notification.message.emailonly": "Recibirás notificaciones por <0>correo electrónico sobre {about}.", - "mysettings.notification.message.none": "<0>NO recibirás ninguna notificación sobre este evento.", - "mysettings.notification.message.webandemail": "Recibirás notificaciones <0>web y por <1>correo electrónico sobre {about}.", - "mysettings.notification.message.webonly": "Recibirás notificaciones <0>web sobre {about}.", "mysettings.notification.title": "Utiliza el siguiente panel para elegir sobre cuáles eventos quieres recibir notificaciones", "mysettings.page.subtitle": "Administra la configuración de tu perfil", "mysettings.page.title": "Configuración", - "newpost.modal.addimage": "Agregar imágenes", "newpost.modal.description.placeholder": "Cuéntanoslo. Explícalo con todo detalle, sin reservas. Cuanta más información, mejor.", "newpost.modal.submit": "Envía tu idea", "newpost.modal.title": "Comparte tu idea...", "newpost.modal.title.label": "Dale un título a tu idea", "newpost.modal.title.placeholder": "Algo corto y conciso, resúmalo en pocas palabras.", "page.backhome": "Llévame a la página de inicio de <0>{0}.", - "page.notinvited.text": "No pudimos encontrar una cuenta asociada a tu correo electrónico.", - "page.notinvited.title": "Sin invitación", "page.pendingactivation.didntreceive": "¿No recibiste el correo electrónico?", "page.pendingactivation.resend": "Reenviar correo electrónico de verificación", "page.pendingactivation.resending": "Reenviando...", "page.pendingactivation.text": "Te hemos enviado un correo electrónico de confirmación con un enlace para activar tu sitio.", "page.pendingactivation.text2": "Por favor, revisa tu bandeja de entrada para activarla.", "page.pendingactivation.title": "Tu cuenta está pendiente de activación", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "No se pudo copiar el enlace del comentario, copie la URL de la página", "showpost.comment.copylink.success": "Enlace de comentario copiado al portapapeles", "showpost.comment.unknownhighlighted": "ID de comentario no válido #{id}", "showpost.commentinput.placeholder": "Publica un comentario", "showpost.copylink.success": "Enlace copiado al portapapeles", - "showpost.discussionpanel.emptymessage": "Nadie ha comentado todavía.", - "showpost.label.author": "Publicado por <0/> · <1/>", "showpost.message.nodescription": "No se proporcionó ninguna descripción.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "Esta operación <0>no se puede deshacer.", "showpost.moderationpanel.text.placeholder": "¿Por qué estás eliminando esta publicación? (opcional)", - "showpost.mostwanted.comments": "{count, plural, one {# comment} other {# comments}}", - "showpost.mostwanted.votes": "{count, plural, one {# votes} other {# votes}}", "showpost.notificationspanel.message.subscribed": "Has recibido notificaciones sobre la actividad en este mensaje.", "showpost.notificationspanel.message.unsubscribed": "No recibirás ninguna notificación sobre este evento.", "showpost.postsearch.numofvotes": "{0} votos", "showpost.postsearch.query.placeholder": "Buscar publicación original...", - "showpost.response.date": "El estado cambió a {status} el {statusDate}", "showpost.responseform.message.mergedvotes": "Los votos de esta publicación se fusionarán en la publicación original.", "showpost.responseform.text.placeholder": "¿Qué está pasando con esta publicación? Dile a tus usuarios cuáles son tus planes...", "showpost.votespanel.more": "+{extraVotesCount} más", @@ -208,7 +216,6 @@ "signin.code.instruction": "Por favor, introduzca el código que le acabamos de enviar a <0>{email}", "signin.code.placeholder": "Escriba el código aquí.", "signin.code.sent": "Se ha enviado un nuevo código a su correo electrónico.", - "signin.code.submit": "Entregar", "signin.email.placeholder": "Dirección de correo electrónico", "signin.message.email": "Continuar con el correo electrónico", "signin.message.emaildisabled": "La autenticación de correo electrónico ha sido deshabilitada por un administrador. Si tiene una cuenta de administrador y necesita eludir esta restricción, por favor <0>haga clic aquí.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "Iniciar sesión con", "signin.name.placeholder": "Su nombre", "validation.custom.maxattachments": "Se permite un máximo de {number} archivos adjuntos.", - "validation.custom.maximagesize": "El tamaño de la imagen debe ser menor que {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# etiqueta} other {# etiquetas}}", - "signin.code.expired": "Este código ha caducado o ya se ha utilizado. Por favor, solicite uno nuevo.", - "signin.code.invalid": "El código que has introducido no es válido. Por favor, inténtalo de nuevo." + "validation.custom.maximagesize": "El tamaño de la imagen debe ser menor que {kilobytes}KB." } \ No newline at end of file diff --git a/locale/fa/client.json b/locale/fa/client.json index 78f2330a0..a70430524 100644 --- a/locale/fa/client.json +++ b/locale/fa/client.json @@ -4,13 +4,15 @@ "action.close": "بستن", "action.commentsfeed": "فید نظرات", "action.confirm": "تأیید", - "action.continue": "ادامه", "action.copylink": "کپی لینک", "action.delete": "حذف", + "action.delete.block": "", "action.edit": "ویرایش", "action.markallasread": "علامت‌گذاری همه به‌عنوان خوانده‌شده", "action.ok": "باشه", "action.postsfeed": "فید پست‌ها", + "action.publish": "", + "action.publish.verify": "", "action.respond": "پاسخ", "action.save": "ذخیره", "action.signin": "ورود", @@ -18,7 +20,6 @@ "action.submit": "ارسال", "action.vote": "به این ایده رأی دهید", "action.voted": "رأی داده شد!", - "d41FkJ": "{count, plural, one {# برچسب} other {# برچسب}}", "editor.markdownmode": "تغییر به ویرایشگر مارک‌داون", "editor.richtextmode": "تغییر به ویرایشگر متن غنی", "enum.poststatus.completed": "تکمیل‌شده", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "مایلیم بدانیم به چه می‌اندیشید.\n\nچه کاری می‌توانیم بهتر انجام دهیم؟ اینجا جایی است برای رأی دادن، بحث و به‌اشتراک‌گذاری ایده‌ها.", "home.lonely.suggestion": "توصیه می‌شود قبل از به‌اشتراک‌گذاری این سایت، <0>حداقل ۳ پیشنهاد ایجاد کنید. محتوای اولیه برای جذب مخاطبان شما مهم است.", "home.lonely.text": "هنوز هیچ پستی ایجاد نشده است.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "مال خود", "home.postfilter.label.status": "وضعیت", - "home.postfilter.label.view": "نمایش", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "بیشترین بحث", "home.postfilter.option.mostwanted": "بیشترین خواسته", "home.postfilter.option.myposts": "پست‌های من", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "بدون برچسب", "home.postfilter.option.recent": "جدیدترین", "home.postfilter.option.trending": "داغ‌ترین", - "home.postinput.description.placeholder": "پیشنهاد خود را توضیح دهید (اختیاری)", "home.postscontainer.label.noresults": "نتیجه‌ای پیدا نشد، مورد دیگری امتحان کنید.", "home.postscontainer.label.viewmore": "مشاهدهٔ پست‌های بیشتر", "home.postscontainer.query.placeholder": "جستجو", "home.postsort.label": "مرتب‌سازی بر اساس:", - "home.similar.subtitle": "در عوض به پست‌های موجود رأی دهید.", "home.similar.title": "پست‌های مشابه", - "home.tagsfilter.label.with": "دارای", - "home.tagsfilter.selected.none": "هر برچسب", - "label.actions": "اقدامات", "label.addtags": "اضافه کردن برچسب‌ها...", "label.avatar": "آواتار", "label.custom": "سفارشی", - "label.description": "توضیحات", "label.discussion": "بحث", "label.edittags": "ویرایش برچسب‌ها", "label.email": "ایمیل", @@ -77,36 +74,27 @@ "label.following": "در حال دنبال", "label.gravatar": "گراواتار", "label.letter": "حرف", - "label.moderation": "مدیریت", "label.name": "نام", "label.none": "هیچ‌کدام", - "label.notagsavailable": "برچسبی در دسترس نیست", - "label.notagsselected": "برچسبی انتخاب نشده است", "label.notifications": "اعلان‌ها", "label.or": "یا", "label.searchtags": "جستجوی برچسب‌ها...", - "label.selecttags": "برچسب‌ها را انتخاب کنید...", "label.subscribe": "اشتراک", "label.tags": "برچسب‌ها", - "label.unfollow": "لغو دنبال‌کردن", "label.unread": "خوانده‌نشده", "label.unsubscribe": "لغو اشتراک", "label.voters": "رأی‌دهندگان", "labels.notagsavailable": "برچسبی در دسترس نیست", - "labels.notagsselected": "برچسبی انتخاب نشده است", "legal.agreement": "من <0/> و <1/> را خوانده‌ام و قبول دارم.", - "legal.notice": "با ورود، شما با <2/><0/> و <1/> موافقید.", + "legal.notice": "با ورود، شما با <0/><1/> و <2/> موافقید.", "legal.privacypolicy": "سیاست حفظ حریم خصوصی", "legal.termsofservice": "شرایط خدمات", "linkmodal.insert": "درج لینک", "linkmodal.text.label": "متن برای نمایش", "linkmodal.text.placeholder": "متن لینک را وارد کنید", - "linkmodal.text.required": "متن الزامی است", "linkmodal.title": "درج لینک", - "linkmodal.url.invalid": "لطفا یک URL معتبر وارد کنید", "linkmodal.url.label": "آدرس اینترنتی", "linkmodal.url.placeholder": "", - "linkmodal.url.required": "آدرس اینترنتی (URL) الزامی است", "menu.administration": "مدیریت", "menu.mysettings": "تنظیمات من", "menu.signout": "خروج", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "کاربری با <0>{0} یافت نشد.", "modal.showvotes.query.placeholder": "جستجوی کاربر بر اساس نام...", "modal.signin.header": "بازخورد خود را ارسال کنید", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "خوانده‌شده در ۳۰ روز اخیر.", "mynotifications.message.nounread": "اعلان خوانده‌نشده‌ای نیست.", "mynotifications.page.subtitle": "در جریان اتفاقات بمانید", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "ایمیل", "mysettings.notification.channelweb": "وب", "mysettings.notification.event.discussion": "بحث", - "mysettings.notification.event.discussion.staff": "نظرات همهٔ پست‌ها مگر این‌که لغو اشتراک شده باشد", - "mysettings.notification.event.discussion.visitors": "نظرات پست‌هایی که مشترک هستید", "mysettings.notification.event.mention": "منشن‌ها", "mysettings.notification.event.newpost": "پست جدید", - "mysettings.notification.event.newpost.staff": "پست‌های جدید این سایت", - "mysettings.notification.event.newpost.visitors": "پست‌های جدید این سایت", "mysettings.notification.event.newpostcreated": "ایده شما اضافه شد 👍", "mysettings.notification.event.statuschanged": "تغییر وضعیت", - "mysettings.notification.event.statuschanged.staff": "تغییر وضعیت همهٔ پست‌ها مگر این‌که لغو اشتراک شده باشد", - "mysettings.notification.event.statuschanged.visitors": "تغییر وضعیت پست‌هایی که مشترک هستید", - "mysettings.notification.message.emailonly": "شما اعلان‌های <0>ایمیلی دربارهٔ {about} دریافت خواهید کرد.", - "mysettings.notification.message.none": "شما <0>هیچ اعلانی دربارهٔ این رویداد دریافت نخواهید کرد.", - "mysettings.notification.message.webandemail": "شما اعلان‌های <0>وب و <1>ایمیل دربارهٔ {about} دریافت خواهید کرد.", - "mysettings.notification.message.webonly": "شما اعلان‌های <0>وب دربارهٔ {about} دریافت خواهید کرد.", "mysettings.notification.title": "رویدادهایی را که می‌خواهید اعلان دریافت کنید انتخاب کنید", "mysettings.page.subtitle": "تنظیمات پروفایل خود را مدیریت کنید", "mysettings.page.title": "تنظیمات", - "newpost.modal.addimage": "اضافه کردن تصاویر", "newpost.modal.description.placeholder": "در موردش به ما بگو. کامل توضیح بده، دریغ نکن، هر چه اطلاعات بیشتر، بهتر.", "newpost.modal.submit": "ایده خود را ثبت کنید", "newpost.modal.title": "ایده خود را به اشتراک بگذارید...", "newpost.modal.title.label": "برای ایده خود عنوان تعیین کنید", "newpost.modal.title.placeholder": "چیزی کوتاه و مختصر، خلاصه در چند کلمه", "page.backhome": "بازگشت به صفحهٔ اصلی <0>{0}", - "page.notinvited.text": "ما حسابی برای آدرس ایمیل شما پیدا نکردیم.", - "page.notinvited.title": "دعوت نشده", "page.pendingactivation.didntreceive": "ایمیل را دریافت نکردید؟", "page.pendingactivation.resend": "ارسال مجدد ایمیل تأیید", "page.pendingactivation.resending": "ارسال مجدد...", "page.pendingactivation.text": "یک ایمیل تأیید به شما ارسال کردیم.", "page.pendingactivation.text2": "برای فعال‌سازی، صندوق ورودی خود را بررسی کنید.", "page.pendingactivation.title": "حساب در انتظار فعال‌سازی", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "کپی لینک نظر ناموفق بود، URL صفحه را کپی کنید", "showpost.comment.copylink.success": "لینک نظر کپی شد", "showpost.comment.unknownhighlighted": "شناسهٔ نظر نامعتبر #{id}", "showpost.commentinput.placeholder": "یک نظر بگذارید", "showpost.copylink.success": "لینک کپی شد", - "showpost.discussionpanel.emptymessage": "هنوز نظری ثبت نشده است.", - "showpost.label.author": "ارسال‌شده توسط <0/> · <1/>", "showpost.message.nodescription": "توضیحی ارائه نشده است.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "این عملیات <0>قابل بازگشت نیست.", "showpost.moderationpanel.text.placeholder": "چرا این پست را حذف می‌کنید؟ (اختیاری)", - "showpost.mostwanted.comments": "{count, plural, one {# دیدگاه} other {# دیدگاه}}", - "showpost.mostwanted.votes": "{count, plural, one {# رأی} other {# رأی}}", "showpost.notificationspanel.message.subscribed": "شما اعلان‌های این پست را دریافت می‌کنید.", "showpost.notificationspanel.message.unsubscribed": "شما اعلانی برای این پست دریافت نخواهید کرد.", "showpost.postsearch.numofvotes": "{0} رأی", "showpost.postsearch.query.placeholder": "جستجوی پست اصلی...", - "showpost.response.date": "وضعیت در {statusDate} به {status} تغییر کرد", "showpost.responseform.message.mergedvotes": "رأی‌های این پست در پست اصلی ادغام می‌شود.", "showpost.responseform.text.placeholder": "برنامهٔ خود را دربارهٔ این پست با کاربران در میان بگذارید...", "showpost.votespanel.more": "+{extraVotesCount} بیشتر", @@ -208,7 +216,6 @@ "signin.code.instruction": "لطفا کدی که به <0>{email} ارسال کردیم را وارد کنید.", "signin.code.placeholder": "کد را اینجا تایپ کنید", "signin.code.sent": "کد جدید به ایمیل شما ارسال شد.", - "signin.code.submit": "ارسال", "signin.email.placeholder": "آدرس ایمیل", "signin.message.email": "ادامه با ایمیل", "signin.message.emaildisabled": "ورود با ایمیل توسط مدیر غیرفعال شده است. اگر مدیر هستید و نیاز به دسترسی دارید <0>اینجا کلیک کنید.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "ورود با", "signin.name.placeholder": "نام شما", "validation.custom.maxattachments": "حداکثر تعداد {number} پیوست مجاز است.", - "validation.custom.maximagesize": "حجم تصویر باید کمتر از {kilobytes}KB باشد.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# برچسب} other {# برچسب}}", - "signin.code.expired": "این کد منقضی شده یا قبلاً استفاده شده است. لطفاً کد جدیدی درخواست کنید.", - "signin.code.invalid": "کدی که وارد کردید نامعتبر است. لطفاً دوباره امتحان کنید." + "validation.custom.maximagesize": "حجم تصویر باید کمتر از {kilobytes}KB باشد." } \ No newline at end of file diff --git a/locale/fr/client.json b/locale/fr/client.json index fcc6fe120..b46d42b5f 100644 --- a/locale/fr/client.json +++ b/locale/fr/client.json @@ -4,13 +4,15 @@ "action.close": "Fermer", "action.commentsfeed": "Flux de commentaires", "action.confirm": "Confirmer", - "action.continue": "Continuer", "action.copylink": "Copier le lien", "action.delete": "Supprimer", + "action.delete.block": "", "action.edit": "Modifier", "action.markallasread": "Tout marquer comme lu", "action.ok": "D'ACCORD", "action.postsfeed": "Flux de publications", + "action.publish": "", + "action.publish.verify": "", "action.respond": "Répondre", "action.save": "Enregistrer", "action.signin": "Se connecter", @@ -18,7 +20,6 @@ "action.submit": "Valider", "action.vote": "Voter pour cette idée", "action.voted": "Votée !", - "d41FkJ": "{count, plural, one {# étiquette} other {# étiquettes}}", "editor.markdownmode": "Basculer vers l'éditeur markdown", "editor.richtextmode": "Basculer vers l'éditeur de texte enrichi", "enum.poststatus.completed": "Terminé", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "Nous aimerions savoir ce que vous pensez.\n\nQue pouvons-nous faire de mieux ? C'est ici que vous pouvez voter, discuter et partager vos idées.", "home.lonely.suggestion": "Il est recommandé de créer <0>au moins 3 suggestions avant de partager ce site. Le contenu initial est important pour commencer à engager votre public.", "home.lonely.text": "Aucun message n'a encore été créé.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "Propre", "home.postfilter.label.status": "Statut", - "home.postfilter.label.view": "Afficher", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "Les plus discutés", "home.postfilter.option.mostwanted": "Les plus votées", "home.postfilter.option.myposts": "Mes publications", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "Non étiqueté", "home.postfilter.option.recent": "Récent", "home.postfilter.option.trending": "Populaire", - "home.postinput.description.placeholder": "Décrivez votre suggestion (facultatif)", "home.postscontainer.label.noresults": "Aucun résultat ne correspond à votre recherche, essayez quelque chose de différent.", "home.postscontainer.label.viewmore": "Voir plus de messages", "home.postscontainer.query.placeholder": "Rechercher", "home.postsort.label": "Trier par :", - "home.similar.subtitle": "Envisagez de voter sur les messages existants.", "home.similar.title": "Messages similaires", - "home.tagsfilter.label.with": "avec", - "home.tagsfilter.selected.none": "N'importe quel tag", - "label.actions": "Actes", "label.addtags": "Ajouter des balises...", "label.avatar": "Avatar", "label.custom": "Personnalisé", - "label.description": "Description", "label.discussion": "Discussion", "label.edittags": "Modifier les étiquettes", "label.email": "E-mail", @@ -77,36 +74,27 @@ "label.following": "Suivi", "label.gravatar": "Gravatar", "label.letter": "Lettre", - "label.moderation": "Modération", "label.name": "Nom", "label.none": "Aucun", - "label.notagsavailable": "Aucune étiquette disponible", - "label.notagsselected": "Aucune balise sélectionnée", "label.notifications": "Notifications", "label.or": "OU", "label.searchtags": "Rechercher des balises...", - "label.selecttags": "Séléctionner des étiquettes...", "label.subscribe": "S'abonner", "label.tags": "Mots clés", - "label.unfollow": "Ne plus suivre", "label.unread": "Non lu", "label.unsubscribe": "Se désabonner", "label.voters": "Votants", "labels.notagsavailable": "Aucune étiquette disponible", - "labels.notagsselected": "Aucune balise sélectionnée", "legal.agreement": "J'ai lu et j'accepte les <0/> et <1/>.", - "legal.notice": "En vous connectant, vous acceptez les <2/><0/> et <1/>.", + "legal.notice": "En vous connectant, vous acceptez les <0/><1/> et <2/>.", "legal.privacypolicy": "Politique de confidentialité", "legal.termsofservice": "Conditions générales d’utilisation", "linkmodal.insert": "Insérer un lien", "linkmodal.text.label": "Texte à afficher", "linkmodal.text.placeholder": "Entrez le texte du lien", - "linkmodal.text.required": "Le texte est obligatoire", "linkmodal.title": "Insérer un lien", - "linkmodal.url.invalid": "Veuillez saisir une URL valide", "linkmodal.url.label": "", "linkmodal.url.placeholder": "https://exemple.com", - "linkmodal.url.required": "L'URL est requise", "menu.administration": "Administration", "menu.mysettings": "Mes paramètres", "menu.signout": "Se déconnecter", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "Aucun utilisateur correspondant à <0>{0}.", "modal.showvotes.query.placeholder": "Rechercher des utilisateurs par nom...", "modal.signin.header": "Envoyer vos commentaires", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "Lu sur les 30 dernier jours.", "mynotifications.message.nounread": "Pas de notification non lue.", "mynotifications.page.subtitle": "Restez au courant de ce qui se passe", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "Adresse e-mail", "mysettings.notification.channelweb": "Web", "mysettings.notification.event.discussion": "Discussion", - "mysettings.notification.event.discussion.staff": "commentaires sur tous les messages sauf si désabonné individuellement", - "mysettings.notification.event.discussion.visitors": "commentaires sur les messages auxquels vous vous êtes abonné", "mysettings.notification.event.mention": "Mentions", "mysettings.notification.event.newpost": "Nouveau message", - "mysettings.notification.event.newpost.staff": "nouveaux messages sur ce site", - "mysettings.notification.event.newpost.visitors": "nouveaux messages sur ce site", "mysettings.notification.event.newpostcreated": "Votre idée a été ajoutée 👍", "mysettings.notification.event.statuschanged": "Statut modifié", - "mysettings.notification.event.statuschanged.staff": "changement de statut sur tous les messages sauf si désabonné individuellement", - "mysettings.notification.event.statuschanged.visitors": "changement de statut sur les messages auxquels vous vous êtes abonné", - "mysettings.notification.message.emailonly": "Vous recevrez des notifications <0>e-mail sur {about}.", - "mysettings.notification.message.none": "Vous n'allez recevoir <0>AUCUNE notification concernant cet événement.", - "mysettings.notification.message.webandemail": "Vous recevrez des notifications <0>web et <1>e-mail sur {about}.", - "mysettings.notification.message.webonly": "Vous recevrez des notifications <0>web sur {about}.", "mysettings.notification.title": "Utiliser le panneau suivant pour choisir pour quels événements vous souhaitez recevoir une notification", "mysettings.page.subtitle": "Gérer les paramètres de votre profil", "mysettings.page.title": "Paramètres", - "newpost.modal.addimage": "Ajouter des images", "newpost.modal.description.placeholder": "Parlez-nous-en. Expliquez-nous tout en détail, sans retenue : plus vous donnez d'informations, mieux c'est.", "newpost.modal.submit": "Soumettez votre idée", "newpost.modal.title": "Partagez votre idée...", "newpost.modal.title.label": "Donnez un titre à votre idée", "newpost.modal.title.placeholder": "Quelque chose de court et percutant, résumez-le en quelques mots", "page.backhome": "Ramenez-moi à la page d'accueil de <0>{0}.", - "page.notinvited.text": "Nous n'avons pas trouvé de compte avec cette adresse e-mail.", - "page.notinvited.title": "Non invité", "page.pendingactivation.didntreceive": "Vous n'avez pas reçu l'e-mail ?", "page.pendingactivation.resend": "Renvoyer l'e-mail de vérification", "page.pendingactivation.resending": "Envoi en cours...", "page.pendingactivation.text": "Nous vous avons envoyé un e-mail de confirmation avec un lien pour activer votre site.", "page.pendingactivation.text2": "Veuillez vérifier votre boîte de réception pour l'activer.", "page.pendingactivation.title": "Votre compte n'est pas activé", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "Impossible de copier le lien du commentaire, veuillez copier l'URL de la page", "showpost.comment.copylink.success": "Lien du commentaire copié dans le presse-papiers", "showpost.comment.unknownhighlighted": "ID de commentaire #{id} invalide", "showpost.commentinput.placeholder": "Rédiger un commentaire", "showpost.copylink.success": "Lien copié dans le presse-papier", - "showpost.discussionpanel.emptymessage": "Personne n'a encore commenté.", - "showpost.label.author": "Posté par <0/> · <1/>", "showpost.message.nodescription": "Aucune description fournie.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "Cette opération <0>ne peut pas être annulée.", "showpost.moderationpanel.text.placeholder": "Pourquoi supprimez-vous ce message ? (optionnel)", - "showpost.mostwanted.comments": "{count, plural, one {# commentaire} other {# commentaires}}", - "showpost.mostwanted.votes": "{count, plural, one {# vote} other {# votes}}", "showpost.notificationspanel.message.subscribed": "Vous recevrez des notifications sur l'activité de ce message.", "showpost.notificationspanel.message.unsubscribed": "Vous ne recevrez aucune notification à propos de ce message.", "showpost.postsearch.numofvotes": "{0} votes", "showpost.postsearch.query.placeholder": "Rechercher le message original...", - "showpost.response.date": "Le statut a été changé en {status} le {statusDate}", "showpost.responseform.message.mergedvotes": "Les votes de ce message seront fusionnés dans le message original.", "showpost.responseform.text.placeholder": "Que se passe-t-il avec ce message ? Faites savoir à vos utilisateurs quels sont vos plans...", "showpost.votespanel.more": "+{extraVotesCount} de plus", @@ -208,7 +216,6 @@ "signin.code.instruction": "Veuillez saisir le code que nous venons d'envoyer à <0>{email}", "signin.code.placeholder": "Saisissez le code ici", "signin.code.sent": "Un nouveau code a été envoyé à votre adresse e-mail.", - "signin.code.submit": "Soumettre", "signin.email.placeholder": "Adresse email", "signin.message.email": "Continuer avec une addresse email", "signin.message.emaildisabled": "L'authentification par e-mail a été désactivée par un administrateur. Si vous avez un compte administrateur, <0>cliquez ici pour vous connecter.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "Se connecter avec", "signin.name.placeholder": "Votre nom", "validation.custom.maxattachments": "Un maximum de {number} pièces jointes est autorisé.", - "validation.custom.maximagesize": "La taille de l'image doit être inférieure à {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "signin.code.expired": "Ce code a expiré ou a déjà été utilisé. Veuillez en demander un nouveau.", - "signin.code.invalid": "Le code que vous avez saisi est invalide. Veuillez réessayer." + "validation.custom.maximagesize": "La taille de l'image doit être inférieure à {kilobytes}KB." } \ No newline at end of file diff --git a/locale/it/client.json b/locale/it/client.json index 008c95cc1..1e27af595 100644 --- a/locale/it/client.json +++ b/locale/it/client.json @@ -4,13 +4,15 @@ "action.close": "Chiudi", "action.commentsfeed": "Feed dei commenti", "action.confirm": "Conferma", - "action.continue": "Continuare", "action.copylink": "Copia il collegamento", "action.delete": "Cancella", + "action.delete.block": "", "action.edit": "Modifica", "action.markallasread": "Segna tutto come letto", "action.ok": "OK", "action.postsfeed": "Feed dei post", + "action.publish": "", + "action.publish.verify": "", "action.respond": "Rispondi", "action.save": "Salva", "action.signin": "Accedi", @@ -18,7 +20,6 @@ "action.submit": "Invia", "action.vote": "Vota questa idea", "action.voted": "Votato!", - "d41FkJ": "{count, plural, one {# tag} other {# tags}}", "editor.markdownmode": "Passa all'editor di markdown", "editor.richtextmode": "Passa all'editor di testo avanzato", "enum.poststatus.completed": "Completato", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "Ci piacerebbe sentire quello che stai pensando.\n\nChe cosa possiamo fare meglio? Questo è il posto per voi per votare, discutere e condividere idee.", "home.lonely.suggestion": "Si consiglia di creare <0>almeno 3 suggerimenti qui prima di condividere questo sito. Il contenuto iniziale è importante per iniziare ad coinvolgere il pubblico.", "home.lonely.text": "Non sono stati inviati messaggi.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "Possedere", "home.postfilter.label.status": "Stato", - "home.postfilter.label.view": "Visualizza", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "Più discussi", "home.postfilter.option.mostwanted": "I più votati", "home.postfilter.option.myposts": "I miei post", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "Senza tag", "home.postfilter.option.recent": "Recenti", "home.postfilter.option.trending": "Di tendenza", - "home.postinput.description.placeholder": "Descrivere il tuo suggerimento (opzionale)", "home.postscontainer.label.noresults": "Nessun risultato corrisponde alla tua ricerca, prova qualcosa di diverso.", "home.postscontainer.label.viewmore": "Vedi altri messaggi", "home.postscontainer.query.placeholder": "Cerca", "home.postsort.label": "Ordina per:", - "home.similar.subtitle": "Considerare invece di votare sui post esistenti.", "home.similar.title": "Post simili", - "home.tagsfilter.label.with": "con", - "home.tagsfilter.selected.none": "Qualsiasi tag", - "label.actions": "Azioni", "label.addtags": "Aggiungi tag...", "label.avatar": "Foto profilo", "label.custom": "Personalizzato", - "label.description": "Descrizione", "label.discussion": "Discussione", "label.edittags": "Modifica tag", "label.email": "E-mail", @@ -77,36 +74,27 @@ "label.following": "Seguente", "label.gravatar": "Gravatar", "label.letter": "Lettera", - "label.moderation": "Moderazione", "label.name": "Nome", "label.none": "Vuoto", - "label.notagsavailable": "Nessun tag disponibile", - "label.notagsselected": "Nessun tag selezionato", "label.notifications": "Notifiche", "label.or": "OPPURE", "label.searchtags": "Cerca tag...", - "label.selecttags": "Seleziona i tag...", "label.subscribe": "Abbonati", "label.tags": "Etichette", - "label.unfollow": "Non seguire più", "label.unread": "Non letto", "label.unsubscribe": "Disiscriversi", "label.voters": "Votanti", "labels.notagsavailable": "Nessun tag disponibile", - "labels.notagsselected": "Nessun tag selezionato", "legal.agreement": "Ho letto e accetto le <0/> e <1/>.", - "legal.notice": "Accedendo, accetti i <2/><0/> e <1/>.", + "legal.notice": "Accedendo, accetti i <0/><1/> e <2/>.", "legal.privacypolicy": "Informativa sulla privacy", "legal.termsofservice": "Termini di servizio", "linkmodal.insert": "Inserisci collegamento", "linkmodal.text.label": "Testo da visualizzare", "linkmodal.text.placeholder": "Inserisci il testo del collegamento", - "linkmodal.text.required": "Il testo è obbligatorio", "linkmodal.title": "Inserisci collegamento", - "linkmodal.url.invalid": "Inserisci un URL valido", "linkmodal.url.label": "", "linkmodal.url.placeholder": "https://esempio.com", - "linkmodal.url.required": "L'URL è obbligatorio", "menu.administration": "Amministrazione", "menu.mysettings": "Le mie preferenze", "menu.signout": "Disconnettersi", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "Nessun utente trovato corrispondente a <0>{0}.", "modal.showvotes.query.placeholder": "Cerca gli utenti per nome...", "modal.signin.header": "Invia il tuo feedback", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "Continua a leggere negli ultimi 30 giorni.", "mynotifications.message.nounread": "Nessuna nuova notifica.", "mynotifications.page.subtitle": "Rimani aggiornato su quello che sta succedendo", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "E-mail", "mysettings.notification.channelweb": "Rete", "mysettings.notification.event.discussion": "Discussione", - "mysettings.notification.event.discussion.staff": "commenti su tutti i post a meno che non vi sia una sottoscrizione individuale", - "mysettings.notification.event.discussion.visitors": "commenti sui post che hai sottoscritto", "mysettings.notification.event.mention": "Menzioni", "mysettings.notification.event.newpost": "Nuovo post", - "mysettings.notification.event.newpost.staff": "nuovi post su questo sito", - "mysettings.notification.event.newpost.visitors": "nuovi post su questo sito", "mysettings.notification.event.newpostcreated": "La tua idea è stata aggiunta 👍", "mysettings.notification.event.statuschanged": "Stato modificato", - "mysettings.notification.event.statuschanged.staff": "cambio di stato su tutti i post a meno che non vi sia una sottoscrizione individuale", - "mysettings.notification.event.statuschanged.visitors": "cambio di stato sui post a cui ti sei iscritto", - "mysettings.notification.message.emailonly": "Riceverai <0>email notifiche su {about}.", - "mysettings.notification.message.none": "<0>NON riceverai una notifica su questo evento.", - "mysettings.notification.message.webandemail": "Riceverai notifiche <0>web e <1>email su {about}.", - "mysettings.notification.message.webonly": "Riceverai <0>web notifiche su {about}.", "mysettings.notification.title": "Usa il pannello seguente per scegliere quali eventi vuoi ricevere una notifica", "mysettings.page.subtitle": "Gestisci le impostazioni del profilo", "mysettings.page.title": "Impostazioni", - "newpost.modal.addimage": "Aggiungi immagini", "newpost.modal.description.placeholder": "Raccontacelo. Spiegalo in dettaglio, non esitare, più informazioni hai, meglio è.", "newpost.modal.submit": "Invia la tua idea", "newpost.modal.title": "Condividi la tua idea...", "newpost.modal.title.label": "Dai un titolo alla tua idea", "newpost.modal.title.placeholder": "Qualcosa di breve e conciso, riassumilo in poche parole", "page.backhome": "Portami indietro alla <0>{0} pagina principale.", - "page.notinvited.text": "Non siamo riusciti a trovare un account per il tuo indirizzo email.", - "page.notinvited.title": "Nessun invito", "page.pendingactivation.didntreceive": "Non hai ricevuto l'email?", "page.pendingactivation.resend": "Invia nuovamente l'email di verifica", "page.pendingactivation.resending": "Reinvio in corso...", "page.pendingactivation.text": "Vi abbiamo inviato un'e-mail di conferma con un link per attivare il vostro sito.", "page.pendingactivation.text2": "Per favore controlla la tua casella e-mail per l’attivazione.", "page.pendingactivation.title": "Il tuo account è in attesa di attivazione", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "Impossibile copiare il collegamento al commento, copiare l'URL della pagina", "showpost.comment.copylink.success": "Link al commento copiato negli appunti", "showpost.comment.unknownhighlighted": "ID commento non valido #{id}", "showpost.commentinput.placeholder": "Lascia un commento", "showpost.copylink.success": "Collegamento copiato negli appunti", - "showpost.discussionpanel.emptymessage": "Nessuno ha ancora commentato.", - "showpost.label.author": "Scritto da <0/>·<1/>", "showpost.message.nodescription": "Nessuna descrizione fornita.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "Questa operano <0>non può essere cancellata.", "showpost.moderationpanel.text.placeholder": "Stai eliminando questo post? (Opzione)", - "showpost.mostwanted.comments": "{count, plural, one {# comment} other {# comments}}", - "showpost.mostwanted.votes": "{count, plural, one {# votes} other {# votes}}", "showpost.notificationspanel.message.subscribed": "Stai ricevendo notifiche sull'attività di questo post.", "showpost.notificationspanel.message.unsubscribed": "Non riceverai alcuna notifica su questo post.", "showpost.postsearch.numofvotes": "{0} voti", "showpost.postsearch.query.placeholder": "Cerca post originale...", - "showpost.response.date": "Stato modificato in {status} il {statusDate}", "showpost.responseform.message.mergedvotes": "I voti di questo post saranno uniti al post originale.", "showpost.responseform.text.placeholder": "Cosa succede con questo post? Fate sapere ai vostri utenti quali sono i vostri piani...", "showpost.votespanel.more": "+{extraVotesCount} di più", @@ -208,7 +216,6 @@ "signin.code.instruction": "Inserisci il codice che abbiamo appena inviato a <0>{email}", "signin.code.placeholder": "Inserisci il codice qui", "signin.code.sent": "Un nuovo codice è stato inviato alla tua email.", - "signin.code.submit": "Invia", "signin.email.placeholder": "Indirizzo e-mail", "signin.message.email": "Continua con l'email", "signin.message.emaildisabled": "L'autenticazione email è stata disabilitata da un amministratore. Se hai un account amministratore e hai bisogno di bypassare questa restrizione, per favore <0>clicca qui.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "Accedi con", "signin.name.placeholder": "Il tuo nome", "validation.custom.maxattachments": "Sono consentiti al massimo {number} allegati.", - "validation.custom.maximagesize": "La dimensione dell'immagine deve essere inferiore a {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "signin.code.expired": "Questo codice è scaduto o è già stato utilizzato. Richiedine uno nuovo.", - "signin.code.invalid": "Il codice inserito non è valido. Riprova." + "validation.custom.maximagesize": "La dimensione dell'immagine deve essere inferiore a {kilobytes}KB." } \ No newline at end of file diff --git a/locale/ja/client.json b/locale/ja/client.json index ea0fc08df..648713ce2 100644 --- a/locale/ja/client.json +++ b/locale/ja/client.json @@ -4,13 +4,15 @@ "action.close": "閉じる", "action.commentsfeed": "コメントフィード", "action.confirm": "確認", - "action.continue": "続く", "action.copylink": "リンクをコピー", "action.delete": "削除", + "action.delete.block": "", "action.edit": "編集", "action.markallasread": "すべて既読にする", "action.ok": "わかりました", "action.postsfeed": "投稿フィード", + "action.publish": "", + "action.publish.verify": "", "action.respond": "回答", "action.save": "保存", "action.signin": "ログイン", @@ -18,7 +20,6 @@ "action.submit": "送信", "action.vote": "このアイデアに投票", "action.voted": "投票完了!", - "d41FkJ": "{count, plural, one {# tag} other {# tags}}", "editor.markdownmode": "マークダウンエディターに切り替える", "editor.richtextmode": "リッチテキストエディタに切り替える", "enum.poststatus.completed": "完了", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "皆さんが考えていることをお聞きかせください。\n\nどう改善できますでしょうか? ここでは、あなたが投票、議論、アイデアを共有する場所です。", "home.lonely.suggestion": "このサイトを共有する前に、<0>少なくとも3の提案をここで作成することをお勧めします。最初のコンテンツは、ユーザーを引き付けるために重要です。", "home.lonely.text": "投稿がまだ作成されていません。", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "自分の", "home.postfilter.label.status": "状態", - "home.postfilter.label.view": "表示", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "最も議論されたもの", "home.postfilter.option.mostwanted": "最も人気のあるもの", "home.postfilter.option.myposts": "私の投稿", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "タグなし", "home.postfilter.option.recent": "新着", "home.postfilter.option.trending": "トレンド", - "home.postinput.description.placeholder": "提案を説明してください(オプション)", "home.postscontainer.label.noresults": "検索に一致する結果はありません。別のものを試してみてください。", "home.postscontainer.label.viewmore": "他の投稿を見る", "home.postscontainer.query.placeholder": "検索", "home.postsort.label": "並べ替え:", - "home.similar.subtitle": "代わりに既存の投稿に投票することを検討してください。", "home.similar.title": "類似の投稿", - "home.tagsfilter.label.with": "と", - "home.tagsfilter.selected.none": "任意のタグ", - "label.actions": "アクション", "label.addtags": "タグを追加...", "label.avatar": "アバター", "label.custom": "カスタム", - "label.description": "説明", "label.discussion": "議論", "label.edittags": "タグを編集", "label.email": "メール", @@ -77,36 +74,27 @@ "label.following": "フォロー中", "label.gravatar": "グラバター", "label.letter": "レター", - "label.moderation": "モデレーション", "label.name": "名前", "label.none": "該当なし", - "label.notagsavailable": "利用可能なタグはありません", - "label.notagsselected": "タグが選択されていません", "label.notifications": "通知", "label.or": "または", "label.searchtags": "タグを検索...", - "label.selecttags": "タグを選択...", "label.subscribe": "購読する", "label.tags": "タグ", - "label.unfollow": "フォロー解除", "label.unread": "未読", "label.unsubscribe": "購読の解除", "label.voters": "投票者", "labels.notagsavailable": "利用可能なタグはありません", - "labels.notagsselected": "タグが選択されていません", "legal.agreement": "私は<0/>と<1/>を読み、同意しました。", - "legal.notice": "サインインすると、<2/><0/> および <1/> に同意したことになります。", + "legal.notice": "サインインすると、<0/><1/> および <2/> に同意したことになります。", "legal.privacypolicy": "プライバシーポリシー", "legal.termsofservice": "サービス利用規約", "linkmodal.insert": "リンクを挿入", "linkmodal.text.label": "表示するテキスト", "linkmodal.text.placeholder": "リンクテキストを入力", - "linkmodal.text.required": "テキストは必須です", "linkmodal.title": "リンクを挿入", - "linkmodal.url.invalid": "有効なURLを入力してください", "linkmodal.url.label": "", "linkmodal.url.placeholder": "", - "linkmodal.url.required": "URLは必須です", "menu.administration": "管理", "menu.mysettings": "ユーザー設定", "menu.signout": "ログアウト", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "<0>{0}に一致するユーザーは見つかりませんでした。", "modal.showvotes.query.placeholder": "名前でユーザーを検索...", "modal.signin.header": "フィードバックを送信", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "過去30日間の記事を読む。", "mynotifications.message.nounread": "未読の通知はありません。", "mynotifications.page.subtitle": "新着情報を確認する。", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "メールアドレス", "mysettings.notification.channelweb": "ウェブサイト", "mysettings.notification.event.discussion": "ディスカッション", - "mysettings.notification.event.discussion.staff": "個別に購読を解除しない限り、すべての投稿へのコメント", - "mysettings.notification.event.discussion.visitors": "購読済みの投稿のコメント", "mysettings.notification.event.mention": "リアクション", "mysettings.notification.event.newpost": "新規投稿", - "mysettings.notification.event.newpost.staff": "このサイトへの新しい投稿", - "mysettings.notification.event.newpost.visitors": "このサイトへの新しい投稿", "mysettings.notification.event.newpostcreated": "あなたのアイデアが追加されました👍", "mysettings.notification.event.statuschanged": "ステータスが変更されました", - "mysettings.notification.event.statuschanged.staff": "個別に購読を解除しない限り、すべての投稿のステータス変更", - "mysettings.notification.event.statuschanged.visitors": "購読済みの投稿のステータス変更", - "mysettings.notification.message.emailonly": "{about} についての<0>メール通知が届きます。", - "mysettings.notification.message.none": "このイベントに関する通知は<0>受信されません。", - "mysettings.notification.message.webandemail": "{about} についての<0>webと<1>email通知が届きます。", - "mysettings.notification.message.webonly": "{about} についての<0>web通知を受け取ります。", "mysettings.notification.title": "通知を受け取るイベントを選択するには、次のパネルを使用してください", "mysettings.page.subtitle": "プロフィール設定の管理", "mysettings.page.title": "設定", - "newpost.modal.addimage": "画像を追加する", "newpost.modal.description.placeholder": "教えてください。遠慮せずに、詳しく説明してください。情報が多ければ多いほど良いです。", "newpost.modal.submit": "アイデアを提出する", "newpost.modal.title": "あなたのアイデアを共有してください...", "newpost.modal.title.label": "アイデアにタイトルをつける", "newpost.modal.title.placeholder": "短くて簡潔なもの、一言でまとめましょう", "page.backhome": "<0>{0}のホームページに戻ります。", - "page.notinvited.text": "あなたのメールアドレスのアカウントが見つかりませんでした", - "page.notinvited.title": "未招待", "page.pendingactivation.didntreceive": "メールが届きませんか?", "page.pendingactivation.resend": "確認メールを再送信", "page.pendingactivation.resending": "再送信中...", "page.pendingactivation.text": "サイトを有効にするためのリンクが記載された確認メールを送信しました。", "page.pendingactivation.text2": "受信トレイをチェックして有効化してください。", "page.pendingactivation.title": "あなたのアカウントは認証待ちです。", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "コメントリンクのコピーに失敗しました。ページURLをコピーしてください。", "showpost.comment.copylink.success": "コメントリンクがクリップボードにコピーされました。", "showpost.comment.unknownhighlighted": "無効なコメントID #{id}", "showpost.commentinput.placeholder": "コメントを書く", "showpost.copylink.success": "リンクをクリップボードにコピーしました", - "showpost.discussionpanel.emptymessage": "コメントがありません。", - "showpost.label.author": "<0/> · <1/>による投稿", "showpost.message.nodescription": "詳細がありません。", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "この操作は元に戻せません<0>。", "showpost.moderationpanel.text.placeholder": "なぜこの投稿を削除するのですか? (任意)", - "showpost.mostwanted.comments": "{count, plural, one {# comment} other {# comments}}", - "showpost.mostwanted.votes": "{count, plural, one {# votes} other {# votes}}", "showpost.notificationspanel.message.subscribed": "この投稿のアクティビティに関する通知を受け取ります。", "showpost.notificationspanel.message.unsubscribed": "この投稿についての通知は届きません。", "showpost.postsearch.numofvotes": "投票数:{0} ", "showpost.postsearch.query.placeholder": "オリジナルの投稿を検索...", - "showpost.response.date": "{status} のステータスが {statusDate}に変更されました", "showpost.responseform.message.mergedvotes": "この投稿からの投票は元の投稿にマージされます。", "showpost.responseform.text.placeholder": "この記事はどうなっていますか? あなたのプランをユーザーに知らせてください...", "showpost.votespanel.more": "+{extraVotesCount} 以上", @@ -208,7 +216,6 @@ "signin.code.instruction": "<0>{email} に送信したコードを入力してください。", "signin.code.placeholder": "ここにコードを入力してください", "signin.code.sent": "新しいコードがあなたのメールに送信されました。", - "signin.code.submit": "提出する", "signin.email.placeholder": "電子メールアドレス", "signin.message.email": "メールで続行", "signin.message.emaildisabled": "メール認証は管理者によって無効にされています。管理者アカウントを持っていて、この制限を回避する必要がある場合は、<0>ここをクリックしてください。", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "ログイン", "signin.name.placeholder": "あなたの名前", "validation.custom.maxattachments": "最大 {number} 個の添付ファイルが許可されます。", - "validation.custom.maximagesize": "画像サイズは{kilobytes}KB未満である必要があります。", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "signin.code.expired": "このコードは期限切れか、既に使用されています。新しいコードをリクエストしてください。", - "signin.code.invalid": "入力したコードは無効です。もう一度お試しください。" + "validation.custom.maximagesize": "画像サイズは{kilobytes}KB未満である必要があります。" } \ No newline at end of file diff --git a/locale/ko/client.json b/locale/ko/client.json index d43e3b68b..0ca1085bf 100644 --- a/locale/ko/client.json +++ b/locale/ko/client.json @@ -84,7 +84,7 @@ "label.voters": "유권자들", "labels.notagsavailable": "사용 가능한 태그가 없습니다", "legal.agreement": "<0/> 및 <1/> 조항을 읽고 동의합니다.", - "legal.notice": "로그인하면 <2/><0/> 및 <1/>에 동의하는 것입니다.", + "legal.notice": "로그인하면 <0/><1/> 및 <2/>에 동의하는 것입니다.", "legal.privacypolicy": "개인정보 보호정책", "legal.termsofservice": "서비스 약관", "menu.administration": "관리", diff --git a/locale/nl/client.json b/locale/nl/client.json index c6837a0b3..51999a2bf 100644 --- a/locale/nl/client.json +++ b/locale/nl/client.json @@ -4,13 +4,15 @@ "action.close": "Sluiten", "action.commentsfeed": "Reactiefeed", "action.confirm": "Bevestigen", - "action.continue": "Doorgaan", "action.copylink": "Link kopiëren", "action.delete": "Verwijderen", + "action.delete.block": "", "action.edit": "Bewerken", "action.markallasread": "Markeer alles als gelezen", "action.ok": "OK", "action.postsfeed": "Berichtenfeed", + "action.publish": "", + "action.publish.verify": "", "action.respond": "Reageren", "action.save": "Opslaan", "action.signin": "Inloggen", @@ -18,7 +20,6 @@ "action.submit": "Verzenden", "action.vote": "Stem op dit idee", "action.voted": "Gestemd!", - "d41FkJ": "{count, plural, one {# tag} other {# tags}}", "editor.markdownmode": "Overschakelen naar markdown-editor", "editor.richtextmode": "Overschakelen naar rich text-editor", "enum.poststatus.completed": "Voltooid", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "We horen graag waar je aan denkt.\n\nWat kunnen we beter doen? Dit is de plek waar je op ideeën kunt stemmen en reageren, en ook je eigen ideeën kunt delen.", "home.lonely.suggestion": "Het is aanbevolen om <0>tenminste 3 ideeën aan te maken voor het delen van de site. De initiële inhoud is belangrijk om jouw publiek te motiveren om deel te nemen.", "home.lonely.text": "Er zijn nog geen berichten aangemaakt.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "Eigen", "home.postfilter.label.status": "", - "home.postfilter.label.view": "Bekijk", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "Meest besproken", "home.postfilter.option.mostwanted": "Meeste stemmen", "home.postfilter.option.myposts": "Mijn berichten", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "Niet getagd", "home.postfilter.option.recent": "Recent", "home.postfilter.option.trending": "Trending", - "home.postinput.description.placeholder": "Beschrijf jouw suggestie (optioneel)", "home.postscontainer.label.noresults": "Er komen geen resultaten overeen met je zoekopdracht, probeer iets anders.", "home.postscontainer.label.viewmore": "Bekijk meer berichten", "home.postscontainer.query.placeholder": "Zoeken", "home.postsort.label": "Sorteren op:", - "home.similar.subtitle": "Overweeg om op vergelijkbare berichten te stemmen.", "home.similar.title": "Vergelijkbare berichten", - "home.tagsfilter.label.with": "met", - "home.tagsfilter.selected.none": "Elk label", - "label.actions": "Acties", "label.addtags": "Tags toevoegen...", "label.avatar": "Profielafbeelding", "label.custom": "Aangepast", - "label.description": "Omschrijving", "label.discussion": "Discussie", "label.edittags": "Tags bewerken", "label.email": "E-mailadres", @@ -77,36 +74,27 @@ "label.following": "Volgend", "label.gravatar": "Gravatar", "label.letter": "Initialen", - "label.moderation": "Moderatie", "label.name": "Naam", "label.none": "Geen", - "label.notagsavailable": "Geen tags beschikbaar", - "label.notagsselected": "Geen tags geselecteerd", "label.notifications": "Meldingen", "label.or": "OF", "label.searchtags": "Zoek tags...", - "label.selecttags": "Tags selecteren...", "label.subscribe": "Abonneren", "label.tags": "Labels", - "label.unfollow": "Ontvolgen", "label.unread": "Ongelezen", "label.unsubscribe": "Uitschrijven", "label.voters": "Stemmers", "labels.notagsavailable": "Geen tags beschikbaar", - "labels.notagsselected": "Geen tags geselecteerd", "legal.agreement": "Ik heb de <0/> en <1/> gelezen en ga hiermee akkoord.", - "legal.notice": "Door in te loggen, ga je akkoord met de <2/><0/> en <1/>.", + "legal.notice": "Door in te loggen, ga je akkoord met de <0/><1/> en <2/>.", "legal.privacypolicy": "Privacybeleid", "legal.termsofservice": "Algemene voorwaarden", "linkmodal.insert": "Link invoegen", "linkmodal.text.label": "Weer te geven tekst", "linkmodal.text.placeholder": "Voer linktekst in", - "linkmodal.text.required": "Tekst is vereist", "linkmodal.title": "Link invoegen", - "linkmodal.url.invalid": "Voer een geldige URL in", "linkmodal.url.label": "", "linkmodal.url.placeholder": "https://voorbeeld.com", - "linkmodal.url.required": "URL is vereist", "menu.administration": "Beheer", "menu.mysettings": "Mijn instellingen", "menu.signout": "Uitloggen", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "Geen gebruikers gevonden voor <0>{0}.", "modal.showvotes.query.placeholder": "Zoek gebruikers op naam...", "modal.signin.header": "Geef uw feedback", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "In de afgelopen 30 dagen gelezen.", "mynotifications.message.nounread": "Geen ongelezen meldingen.", "mynotifications.page.subtitle": "Blijf op de hoogte van wat er gebeurt", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "E-mailadres", "mysettings.notification.channelweb": "Web", "mysettings.notification.event.discussion": "Discussie", - "mysettings.notification.event.discussion.staff": "reacties op alle berichten tenzij individueel uitgeschreven", - "mysettings.notification.event.discussion.visitors": "reacties op berichten waar je voor aangemeld bent", "mysettings.notification.event.mention": "Vermeldingen", "mysettings.notification.event.newpost": "Nieuw Bericht", - "mysettings.notification.event.newpost.staff": "nieuwe berichten op deze site", - "mysettings.notification.event.newpost.visitors": "nieuwe berichten op deze site", "mysettings.notification.event.newpostcreated": "Jouw idee is toegevoegd 👍", "mysettings.notification.event.statuschanged": "Status veranderd", - "mysettings.notification.event.statuschanged.staff": "statuswijzigingen van alle berichten, tenzij individueel afgemeld", - "mysettings.notification.event.statuschanged.visitors": "statuswijzigingen van alle berichten waarop je bent geabonneerd", - "mysettings.notification.message.emailonly": "Je ontvangt meldingen per <0>mail over {about}.", - "mysettings.notification.message.none": "Je ontvangt <0>GEEN meldingen over deze gebeurtenis.", - "mysettings.notification.message.webandemail": "Je ontvangt meldingen per <0>web en <1>mail over {about}.", - "mysettings.notification.message.webonly": "Je ontvangt meldingen per <0>web over {about}.", "mysettings.notification.title": "Gebruik het volgende paneel om te kiezen van welke gebeurtenissen je meldingen wil ontvangen", "mysettings.page.subtitle": "Beheer jouw profielinstellingen", "mysettings.page.title": "Instellingen", - "newpost.modal.addimage": "Afbeeldingen toevoegen", "newpost.modal.description.placeholder": "Vertel het ons. Leg het volledig uit, houd je niet in, hoe meer informatie hoe beter.", "newpost.modal.submit": "Dien uw idee in", "newpost.modal.title": "Deel uw idee...", "newpost.modal.title.label": "Geef je idee een titel", "newpost.modal.title.placeholder": "Iets kort en krachtigs, vat het samen in een paar woorden", "page.backhome": "Ga terug naar <0>{0} startpagina.", - "page.notinvited.text": "We konden geen account vinden voor dit e-mailadres.", - "page.notinvited.title": "Niet uitgenodigd", "page.pendingactivation.didntreceive": "E-mail niet ontvangen?", "page.pendingactivation.resend": "Verificatie-e-mail opnieuw verzenden", "page.pendingactivation.resending": "Opnieuw verzenden...", "page.pendingactivation.text": "We hebben je een bevestigingsmail gestuurd met een link om jouw site te activeren.", "page.pendingactivation.text2": "Controleer je inbox om het te activeren.", "page.pendingactivation.title": "Je account is nog niet geactiveerd", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "Het kopiëren van de commentaarlink is mislukt. Kopieer de URL van de pagina.", "showpost.comment.copylink.success": "Reactielink gekopieerd naar klembord", "showpost.comment.unknownhighlighted": "Ongeldige opmerking-ID #{id}", "showpost.commentinput.placeholder": "Laat een reactie achter", "showpost.copylink.success": "Link gekopieerd naar klembord", - "showpost.discussionpanel.emptymessage": "Nog niemand heeft gereageerd.", - "showpost.label.author": "Geplaatst door <0/> · <1/>", "showpost.message.nodescription": "Geen omschrijving opgegeven.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "Deze bewerking kan <0>niet ongedaan gemaakt worden.", "showpost.moderationpanel.text.placeholder": "Waarom verwijder je dit bericht? (optioneel)", - "showpost.mostwanted.comments": "{count, plural, one {# comment} other {# comments}}", - "showpost.mostwanted.votes": "{count, plural, one {# votes} other {# votes}}", "showpost.notificationspanel.message.subscribed": "Je ontvangt meldingen over activiteiten op dit bericht.", "showpost.notificationspanel.message.unsubscribed": "Je ontvangt geen meldingen over dit bericht.", "showpost.postsearch.numofvotes": "{0} stemmen", "showpost.postsearch.query.placeholder": "Zoek origineel bericht...", - "showpost.response.date": "Status gewijzigd naar {status} op {statusDate}", "showpost.responseform.message.mergedvotes": "Stemmen van dit bericht zullen worden samengevoegd met het originele bericht.", "showpost.responseform.text.placeholder": "Wat gebeurt er met dit bericht? Laat je gebruikers weten wat je plannen zijn...", "showpost.votespanel.more": "+{extraVotesCount} meer", @@ -208,7 +216,6 @@ "signin.code.instruction": "Typ de code in die we zojuist naar <0>{email} hebben gestuurd", "signin.code.placeholder": "Typ hier de code in", "signin.code.sent": "Er is een nieuwe code naar uw e-mailadres verzonden.", - "signin.code.submit": "Indienen", "signin.email.placeholder": "E-mailadres", "signin.message.email": "Doorgaan met e-mail", "signin.message.emaildisabled": "Inloggen met e-mail is uitgeschakeld door een beheerder. Als u een beheerder account heeft en deze beperking moet omzeilen, <0>klik dan hier.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "Inloggen met", "signin.name.placeholder": "Jouw naam", "validation.custom.maxattachments": "Er zijn maximaal {number} bijlagen toegestaan.", - "validation.custom.maximagesize": "De afbeeldingsgrootte moet kleiner zijn dan {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "signin.code.expired": "Deze code is verlopen of al gebruikt. Vraag een nieuwe aan.", - "signin.code.invalid": "De ingevoerde code is ongeldig. Probeer het opnieuw." + "validation.custom.maximagesize": "De afbeeldingsgrootte moet kleiner zijn dan {kilobytes}KB." } \ No newline at end of file diff --git a/locale/pl/client.json b/locale/pl/client.json index 285454b70..0126c2729 100644 --- a/locale/pl/client.json +++ b/locale/pl/client.json @@ -4,13 +4,15 @@ "action.close": "Zamknij", "action.commentsfeed": "Kanał komentarzy", "action.confirm": "Potwierdź", - "action.continue": "Kontynuować", "action.copylink": "Kopiuj link", "action.delete": "Usuń", + "action.delete.block": "", "action.edit": "Edytuj", "action.markallasread": "Oznacz wszystkie jako przeczytane", "action.ok": "OK", "action.postsfeed": "Kanał postów", + "action.publish": "", + "action.publish.verify": "", "action.respond": "Odpowiedz", "action.save": "Zapisz", "action.signin": "Zaloguj się", @@ -18,7 +20,6 @@ "action.submit": "Prześlij", "action.vote": "Zagłosuj na ten pomysł", "action.voted": "Zagłosowane!", - "d41FkJ": "{count, plural, one {# tag} other {# tags}}", "editor.markdownmode": "Przełącz na edytor Markdown", "editor.richtextmode": "Przełącz na edytor tekstu", "enum.poststatus.completed": "Ukończone", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "Chcemy się dowiedzieć o czym myślisz.\n\nCo możemy poprawić? To jest miejsce, w którym możesz głosować, dyskutować i dzielić się pomysłami.", "home.lonely.suggestion": "Zaleca się stworzenie tutaj <0>co najmniej 3 sugestii przed udostępnieniem tej witryny. Początkowa zawartość jest ważna do zaangażowania swoich odbiorców.", "home.lonely.text": "Nie stworzono jeszcze żadnych postów.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "Własny", "home.postfilter.label.status": "", - "home.postfilter.label.view": "Widok", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "Najczęściej dyskutowane", "home.postfilter.option.mostwanted": "Najbardziej Pożądane", "home.postfilter.option.myposts": "Moje posty", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "Nieoznaczone", "home.postfilter.option.recent": "Niedawne", "home.postfilter.option.trending": "Popularne", - "home.postinput.description.placeholder": "Opisz swoją sugestię (opcjonalnie)", "home.postscontainer.label.noresults": "Brak wyników pasujących do Twojego wyszukiwania, spróbuj czegoś innego.", "home.postscontainer.label.viewmore": "Pokaż więcej postów", "home.postscontainer.query.placeholder": "Szukaj", "home.postsort.label": "Sortuj według:", - "home.similar.subtitle": "Zamiast tego rozważ głosowanie na istniejące posty.", "home.similar.title": "Podobne posty", - "home.tagsfilter.label.with": "z", - "home.tagsfilter.selected.none": "Dowolny tag", - "label.actions": "Akcje", "label.addtags": "Dodaj tagi...", "label.avatar": "Awatar", "label.custom": "Niestandardowy", - "label.description": "Opis", "label.discussion": "Dyskusja", "label.edittags": "Edytuj tagi", "label.email": "E-mail", @@ -77,36 +74,27 @@ "label.following": "Następny", "label.gravatar": "Gravatar", "label.letter": "Litera", - "label.moderation": "Moderacja", "label.name": "Nazwa", "label.none": "Brak", - "label.notagsavailable": "Brak dostępnych tagów", - "label.notagsselected": "Nie wybrano tagów", "label.notifications": "Powiadomienia", "label.or": "LUB", "label.searchtags": "Szukaj tagów...", - "label.selecttags": "Wybierz tagi...", "label.subscribe": "Subskrybuj", "label.tags": "Tagi", - "label.unfollow": "Przestań obserwować", "label.unread": "Nieprzeczytane", "label.unsubscribe": "Zrezygnuj z subskrypcji", "label.voters": "Głosujący", "labels.notagsavailable": "Brak dostępnych tagów", - "labels.notagsselected": "Nie wybrano tagów", "legal.agreement": "Przeczytałem i zgadzam się z <0/> i <1/>.", - "legal.notice": "Logując się, akceptujesz <2/><0/> i <1/>.", + "legal.notice": "Logując się, akceptujesz <0/><1/> i <2/>.", "legal.privacypolicy": "Polityka Prywatności", "legal.termsofservice": "Warunki Świadczenia Usług", "linkmodal.insert": "Wstaw link", "linkmodal.text.label": "Tekst do wyświetlenia", "linkmodal.text.placeholder": "Wprowadź tekst linku", - "linkmodal.text.required": "Tekst jest wymagany", "linkmodal.title": "Wstaw link", - "linkmodal.url.invalid": "Proszę podać prawidłowy adres URL", "linkmodal.url.label": "Adres URL", "linkmodal.url.placeholder": "", - "linkmodal.url.required": "Adres URL jest wymagany", "menu.administration": "Administracja", "menu.mysettings": "Moje Ustawienia", "menu.signout": "Wyloguj się", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "Nie znaleziono użytkowników pasujących do <0>{0}.", "modal.showvotes.query.placeholder": "Wyszukaj użytkowników według nazwy...", "modal.signin.header": "Prześlij swoją opinię", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "Przeczytaj ostatnie 30 dni.", "mynotifications.message.nounread": "Brak nowych powiadomień.", "mynotifications.page.subtitle": "Bądź na bieżąco z tym, co się dzieje", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "E-mail", "mysettings.notification.channelweb": "Sieć", "mysettings.notification.event.discussion": "Dyskusja", - "mysettings.notification.event.discussion.staff": "komentarze do wszystkich postów, chyba że zostały indywidualnie odsubskrybowane", - "mysettings.notification.event.discussion.visitors": "komentarze postów które zasubskrybowałeś", "mysettings.notification.event.mention": "Wzmianki", "mysettings.notification.event.newpost": "Nowy post", - "mysettings.notification.event.newpost.staff": "nowe posty na tej stronie", - "mysettings.notification.event.newpost.visitors": "nowe posty na tej stronie", "mysettings.notification.event.newpostcreated": "Twój pomysł został dodany 👍", "mysettings.notification.event.statuschanged": "Zmieniono status", - "mysettings.notification.event.statuschanged.staff": "zmiana statusu wszystkich postów, chyba że zostały indywidualnie odsubskrybowane", - "mysettings.notification.event.statuschanged.visitors": "zmiana statusu postów, które zasubskrybowałeś", - "mysettings.notification.message.emailonly": "Otrzymasz powiadomienia <0>email o {about}.", - "mysettings.notification.message.none": "<0>NOT otrzymasz powiadomienie o tym wydarzeniu.", - "mysettings.notification.message.webandemail": "Otrzymasz powiadomienia <0>w przeglądarce i powiadomienia email <1>email na temat {about}.", - "mysettings.notification.message.webonly": "Otrzymasz powiadomienia <0>w przeglądarce na temat {about}.", "mysettings.notification.title": "Użyj następującego panelu, aby wybrać zdarzenia z których chciałbyś otrzymywać powiadomienia", "mysettings.page.subtitle": "Zarządzaj ustawieniami profilu", "mysettings.page.title": "Ustawienia", - "newpost.modal.addimage": "Dodaj obrazy", "newpost.modal.description.placeholder": "Opowiedz nam o tym. Wyjaśnij to dokładnie, nie powstrzymuj się, im więcej informacji, tym lepiej.", "newpost.modal.submit": "Prześlij swój pomysł", "newpost.modal.title": "Podziel się swoim pomysłem...", "newpost.modal.title.label": "Nadaj swojemu pomysłowi tytuł", "newpost.modal.title.placeholder": "Coś krótkiego i zwięzłego, podsumuj to w kilku słowach", "page.backhome": "Zabierz mnie z powrotem na stronę główną <0>{0}.", - "page.notinvited.text": "Nie znaleźliśmy konta powiązanego z Twoim adresem email.", - "page.notinvited.title": "Niezaproszony", "page.pendingactivation.didntreceive": "Nie otrzymałeś wiadomości e-mail?", "page.pendingactivation.resend": "Wyślij ponownie e-mail weryfikacyjny", "page.pendingactivation.resending": "Wysyłanie ponowne...", "page.pendingactivation.text": "Wysłaliśmy do Ciebie maila z linkiem do aktywowania Twojej strony.", "page.pendingactivation.text2": "Sprawdź swoją skrzynkę odbiorczą, aby ją aktywować.", "page.pendingactivation.title": "Twoje konto oczekuje na aktywację", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "Nie udało się skopiować linku do komentarza, skopiuj adres URL strony", "showpost.comment.copylink.success": "Link do komentarza skopiowano do schowka", "showpost.comment.unknownhighlighted": "Nieprawidłowy identyfikator komentarza #{id}", "showpost.commentinput.placeholder": "Skomentuj", "showpost.copylink.success": "Link skopiowany do schowka", - "showpost.discussionpanel.emptymessage": "Wygląda na to, że nikt jeszcze nie skomentował.", - "showpost.label.author": "Wysłane przez <0/> · <1/>", "showpost.message.nodescription": "Brak opisu.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "Ta akcja <0>nie może zostać wykonana.", "showpost.moderationpanel.text.placeholder": "Dlaczego usuwasz ten wpis? (opcjonalne)", - "showpost.mostwanted.comments": "{count, plural, one {# comment} other {# comments}}", - "showpost.mostwanted.votes": "{count, plural, one {# votes} other {# votes}}", "showpost.notificationspanel.message.subscribed": "Otrzymujesz powiadomienia o aktywności tego posta.", "showpost.notificationspanel.message.unsubscribed": "Nie będziesz otrzymywał żadnych powiadomień o o tym poście.", "showpost.postsearch.numofvotes": "{0} głosów", "showpost.postsearch.query.placeholder": "Szukaj oryginalnego posta...", - "showpost.response.date": "Status zmieniono na {status} dnia {statusDate}", "showpost.responseform.message.mergedvotes": "Głosy z tego posta zostaną scalone z oryginalnym postem.", "showpost.responseform.text.placeholder": "Co się dzieje w temacie tego posta? Daj swoim użytkownikom znać o swoich planach...", "showpost.votespanel.more": "+{extraVotesCount} więcej", @@ -208,7 +216,6 @@ "signin.code.instruction": "Proszę wpisać kod, który właśnie wysłaliśmy na adres <0>{email}", "signin.code.placeholder": "Wpisz tutaj kod", "signin.code.sent": "Nowy kod został wysłany na Twój adres e-mail.", - "signin.code.submit": "Składać", "signin.email.placeholder": "Adres e-mail", "signin.message.email": "Kontynuuj z e-mailem", "signin.message.emaildisabled": "Autoryzacja za pomocą adresu email została wyłączona przez administratora. Jeśli posiadasz konto administratora i chcesz obejść to ograniczenie <0>kliknij tutaj.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "Zaloguj się za pomocą", "signin.name.placeholder": "Twoje imię", "validation.custom.maxattachments": "Maksymalna liczba załączników to {number}.", - "validation.custom.maximagesize": "Rozmiar obrazu musi być mniejszy niż {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} few {# tagów} many {# tagów} other {# tagi}}", - "signin.code.expired": "Ten kod wygasł lub został już wykorzystany. Poproś o nowy.", - "signin.code.invalid": "Podany kod jest nieprawidłowy. Spróbuj ponownie." + "validation.custom.maximagesize": "Rozmiar obrazu musi być mniejszy niż {kilobytes}KB." } \ No newline at end of file diff --git a/locale/pt-BR/client.json b/locale/pt-BR/client.json index 232e99447..74c607f2c 100644 --- a/locale/pt-BR/client.json +++ b/locale/pt-BR/client.json @@ -4,13 +4,15 @@ "action.close": "Fechar", "action.commentsfeed": "Feed de comentários", "action.confirm": "Confirmar", - "action.continue": "Continuar", "action.copylink": "Copiar link", "action.delete": "Deletar", + "action.delete.block": "", "action.edit": "Editar", "action.markallasread": "Marcar todas como lidas", "action.ok": "OK", "action.postsfeed": "Feed de postagens", + "action.publish": "", + "action.publish.verify": "", "action.respond": "Responder", "action.save": "Salvar", "action.signin": "Iniciar sessão", @@ -18,7 +20,6 @@ "action.submit": "Enviar", "action.vote": "Votar", "action.voted": "Votado", - "d41FkJ": "{count, plural, one {# tag} other {# tags}}", "editor.markdownmode": "Alternar para o editor de markdown", "editor.richtextmode": "Alternar para editor de texto avançado", "enum.poststatus.completed": "Completado", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "Gostaríamos de ouvir sobre o que você está pensando.\n\nO que podemos fazer melhor? Este é o lugar para você votar, discutir e compartilhar ideias.", "home.lonely.suggestion": "É recomendável que você crie <0>pelo menos 3 sugestões aqui antes de compartilhar este site. O conteúdo inicial é importante para começar a engajar seu público.", "home.lonely.text": "Nenhuma postagem foi criada ainda.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "Ter", "home.postfilter.label.status": "", - "home.postfilter.label.view": "Visualizar", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "Mais Discutidos", "home.postfilter.option.mostwanted": "Mais Desejados", "home.postfilter.option.myposts": "Minhas postagens", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "Sem etiqueta", "home.postfilter.option.recent": "Recentes", "home.postfilter.option.trending": "Popular", - "home.postinput.description.placeholder": "Descreva sua sugestão (opcional)", "home.postscontainer.label.noresults": "Nenhum resultado corresponde à sua busca, tente algo diferente.", "home.postscontainer.label.viewmore": "Ver mais postagens", "home.postscontainer.query.placeholder": "Pesquisa", "home.postsort.label": "Ordenar por:", - "home.similar.subtitle": "Considere votar em postagens existentes.", "home.similar.title": "Postagens similares", - "home.tagsfilter.label.with": "com", - "home.tagsfilter.selected.none": "Qualquer tag", - "label.actions": "Ações", "label.addtags": "Adicionar tags...", "label.avatar": "Avatar", "label.custom": "Personalizado", - "label.description": "Descrição", "label.discussion": "Discussão", "label.edittags": "Editar tags", "label.email": "E-mail", @@ -77,36 +74,27 @@ "label.following": "Seguindo", "label.gravatar": "Gravatar", "label.letter": "Letra", - "label.moderation": "Moderação", "label.name": "Nome", "label.none": "Nenhum", - "label.notagsavailable": "Não há tags disponíveis", - "label.notagsselected": "Nenhuma tag selecionada", "label.notifications": "Notificações", "label.or": "OU", "label.searchtags": "Pesquisar tags...", - "label.selecttags": "Escolha as tags...", "label.subscribe": "Inscrever-se", "label.tags": "Etiquetas", - "label.unfollow": "Deixar de seguir", "label.unread": "Não lidas", "label.unsubscribe": "Desinscrever-se", "label.voters": "Votantes", "labels.notagsavailable": "Não há tags disponíveis", - "labels.notagsselected": "Nenhuma tag selecionada", "legal.agreement": "Eu li e concordo com os <0/> e <1/>.", - "legal.notice": "Ao fazer o login, você concorda com os <2/><0/> e <1/>.", + "legal.notice": "Ao fazer o login, você concorda com os <0/><1/> e <2/>.", "legal.privacypolicy": "Política de Privacidade", "legal.termsofservice": "Termos do Serviço", "linkmodal.insert": "Inserir link", "linkmodal.text.label": "Texto para exibir", "linkmodal.text.placeholder": "Insira o texto do link", - "linkmodal.text.required": "Texto é obrigatório", "linkmodal.title": "Inserir link", - "linkmodal.url.invalid": "Por favor, insira um URL válido", "linkmodal.url.label": "", "linkmodal.url.placeholder": "", - "linkmodal.url.required": "URL é obrigatório", "menu.administration": "Administração", "menu.mysettings": "Minhas Configurações", "menu.signout": "Finalizar sessão", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "Nenhum usuário encontrado para <0>{0}.", "modal.showvotes.query.placeholder": "Procurar usuários por nome...", "modal.signin.header": "Enviar seu feedback", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "Lido nos últimos 30 dias.", "mynotifications.message.nounread": "Nenhuma notificação não lida.", "mynotifications.page.subtitle": "Mantenha-se atualizado com o que está acontecendo", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "E-mail", "mysettings.notification.channelweb": "Rede", "mysettings.notification.event.discussion": "Discussão", - "mysettings.notification.event.discussion.staff": "comentários em todas as postagens, a menos que individualmente desinscritas", - "mysettings.notification.event.discussion.visitors": "comentários em postagens que você se inscreveu", "mysettings.notification.event.mention": "Menções", "mysettings.notification.event.newpost": "Nova postagem", - "mysettings.notification.event.newpost.staff": "novas postagens nesse site", - "mysettings.notification.event.newpost.visitors": "novas postagens nesse site", "mysettings.notification.event.newpostcreated": "Sua ideia foi adicionada 👍", "mysettings.notification.event.statuschanged": "Alteração de status", - "mysettings.notification.event.statuschanged.staff": "alteração de status em todas as postagens, a menos que individualmente desinscritas", - "mysettings.notification.event.statuschanged.visitors": "alteração de status em postagens que você se inscreveu", - "mysettings.notification.message.emailonly": "Você receberá notificações por <0>email sobre {about}.", - "mysettings.notification.message.none": "Você <0>NÃO receberá qualquer notificação sobre este evento.", - "mysettings.notification.message.webandemail": "Você receberá notificações por <0>web e <1>e-mail sobre {about}.", - "mysettings.notification.message.webonly": "Você receberá notificações por <0>web sobre {about}.", "mysettings.notification.title": "Use o painel a seguir para escolher quais eventos você gostaria de ser notificado", "mysettings.page.subtitle": "Gerenciar suas configurações de perfil", "mysettings.page.title": "Configurações", - "newpost.modal.addimage": "Adicionar imagens", "newpost.modal.description.placeholder": "Conte-nos sobre isso. Explique tudo detalhadamente, não se esconda, quanto mais informações, melhor.", "newpost.modal.submit": "Envie sua ideia", "newpost.modal.title": "Compartilhe sua ideia...", "newpost.modal.title.label": "Dê um título à sua ideia", "newpost.modal.title.placeholder": "Algo curto e rápido, resuma em poucas palavras", "page.backhome": "Me leve de volta para a página inicial <0>{0}.", - "page.notinvited.text": "Não encontramos nenhuma conta para o seu endereço de e-mail.", - "page.notinvited.title": "Sem convite", "page.pendingactivation.didntreceive": "Não recebeu o e-mail?", "page.pendingactivation.resend": "Reenviar e-mail de verificação", "page.pendingactivation.resending": "Reenviando...", "page.pendingactivation.text": "Enviamos a você um e-mail de confirmação com um link para ativar o seu site.", "page.pendingactivation.text2": "Verifique sua caixa de entrada para ativá-la.", "page.pendingactivation.title": "Sua conta está com ativação pendente", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "Falha ao copiar o link do comentário, copie a URL da página", "showpost.comment.copylink.success": "Link do comentário copiado para área de transferência", "showpost.comment.unknownhighlighted": "ID de comentário #{id} inválido", "showpost.commentinput.placeholder": "Deixe um comentário", "showpost.copylink.success": "Link copiado para a área de transferência", - "showpost.discussionpanel.emptymessage": "Ninguém comentou ainda.", - "showpost.label.author": "Publicado por <0/> · <1/>", "showpost.message.nodescription": "Nenhuma descrição fornecida.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "Esta operação <0>não pode ser desfeita.", "showpost.moderationpanel.text.placeholder": "Por que você está excluindo esta postagem? (opcional)", - "showpost.mostwanted.comments": "{count, plural, one {# comment} other {# comments}}", - "showpost.mostwanted.votes": "{count, plural, one {# voto} other {# votos}}", "showpost.notificationspanel.message.subscribed": "Você está recebendo notificações sobre a atividade nesta postagem.", "showpost.notificationspanel.message.unsubscribed": "Você não receberá nenhuma notificação sobre esta postagem.", "showpost.postsearch.numofvotes": "{0} votos", "showpost.postsearch.query.placeholder": "Procurar postagem original...", - "showpost.response.date": "Status alterado para {status} em {statusDate}", "showpost.responseform.message.mergedvotes": "Votos desta publicação serão mesclados na postagem original.", "showpost.responseform.text.placeholder": "O que está acontecendo com esta postagem? Informe seus usuários quais são os seus planos...", "showpost.votespanel.more": "+{extraVotesCount} mais", @@ -208,7 +216,6 @@ "signin.code.instruction": "Por favor, digite o código que acabamos de enviar para <0>{email}", "signin.code.placeholder": "Digite o código aqui", "signin.code.sent": "Um novo código foi enviado para o seu e-mail.", - "signin.code.submit": "Enviar", "signin.email.placeholder": "Endereço de e-mail", "signin.message.email": "Entrar com email", "signin.message.emaildisabled": "A autenticação por e-mail foi desativada por um administrador. Se você tem uma conta de administrador e precisa ignorar esta restrição, <0>clique aqui.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "Fazer login com", "signin.name.placeholder": "Seu nome", "validation.custom.maxattachments": "São permitidos no máximo {number} anexos.", - "validation.custom.maximagesize": "O tamanho da imagem deve ser menor que {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "signin.code.expired": "Este código expirou ou já foi usado. Solicite um novo.", - "signin.code.invalid": "O código que você digitou é inválido. Por favor, tente novamente." + "validation.custom.maximagesize": "O tamanho da imagem deve ser menor que {kilobytes}KB." } \ No newline at end of file diff --git a/locale/ru/client.json b/locale/ru/client.json index 0552416ff..fa49377bf 100644 --- a/locale/ru/client.json +++ b/locale/ru/client.json @@ -4,13 +4,15 @@ "action.close": "Закрыть", "action.commentsfeed": "Лента комментариев", "action.confirm": "Подтвердить", - "action.continue": "Продолжать", "action.copylink": "Копировать ссылку", "action.delete": "Удалить", + "action.delete.block": "", "action.edit": "Изменить", "action.markallasread": "Отметить всё как прочитанное", "action.ok": "ХОРОШО", "action.postsfeed": "Лента сообщений", + "action.publish": "", + "action.publish.verify": "", "action.respond": "Ответить", "action.save": "Сохранить", "action.signin": "Войти", @@ -18,7 +20,6 @@ "action.submit": "Продолжить", "action.vote": "Проголосуйте за эту идею", "action.voted": "Проголосовал!", - "d41FkJ": "{count, plural, one {# tag} other {# tags}}", "editor.markdownmode": "Переключиться на редактор разметки", "editor.richtextmode": "Переключиться на редактор форматированного текста", "enum.poststatus.completed": "Выполнено", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "Мы хотели бы услышать ваше мнение. \n\nКак мы можем улучшиться? Здесь можно голосовать, обсуждать и делиться идеями.", "home.lonely.suggestion": "Советуем создать <0>хотя бы 3 предложения перед публикацией этого сайта. Это важно для большего вовлечения аудитории.", "home.lonely.text": "Здесь пока нет постов.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "Собственный", "home.postfilter.label.status": "Статус", - "home.postfilter.label.view": "Просмотреть", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "Наиболее обсуждаемые", "home.postfilter.option.mostwanted": "Наиболее востребованные", "home.postfilter.option.myposts": "Мои сообщения", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "Без тегов", "home.postfilter.option.recent": "Недавние", "home.postfilter.option.trending": "В тренде", - "home.postinput.description.placeholder": "Опишите своё предложение (по желанию)", "home.postscontainer.label.noresults": "Не удалось ничего найти. Попробуйте поискать что-то другое.", "home.postscontainer.label.viewmore": "Посмотреть больше постов", "home.postscontainer.query.placeholder": "Поиск", "home.postsort.label": "Сортировать по:", - "home.similar.subtitle": "Возможно, стоит проголосовать за уже существующие посты.", "home.similar.title": "Похожие посты", - "home.tagsfilter.label.with": "с", - "home.tagsfilter.selected.none": "Любые теги", - "label.actions": "Действия", "label.addtags": "Добавить теги...", "label.avatar": "Аватар", "label.custom": "Пользовательский", - "label.description": "Описание", "label.discussion": "Обсуждение", "label.edittags": "Редактировать теги", "label.email": "Адрес электронной почты", @@ -77,36 +74,27 @@ "label.following": "Отслеживаю", "label.gravatar": "Граватар", "label.letter": "Буквенный", - "label.moderation": "Модерация", "label.name": "Имя пользователя", "label.none": "Нет", - "label.notagsavailable": "Нет доступных тегов", - "label.notagsselected": "Теги не выбраны", "label.notifications": "Уведомления", "label.or": "ИЛИ", "label.searchtags": "Поиск тегов...", - "label.selecttags": "Выберите теги...", "label.subscribe": "Подписаться", "label.tags": "Теги", - "label.unfollow": "Отписаться", "label.unread": "Непрочитанные", "label.unsubscribe": "Отписаться", "label.voters": "Проголосовавшие", "labels.notagsavailable": "Нет доступных тегов", - "labels.notagsselected": "Теги не выбраны", "legal.agreement": "Я прочитал и согласен с <0/> и <1/>.", - "legal.notice": "Войдя в систему, вы соглашаетесь с <2/><0/> и <1/>.", + "legal.notice": "Войдя в систему, вы соглашаетесь с <0/><1/> и <2/>.", "legal.privacypolicy": "Политика Конфиденциальности", "legal.termsofservice": "Условия Использования", "linkmodal.insert": "Вставить ссылку", "linkmodal.text.label": "Текст для отображения", "linkmodal.text.placeholder": "Введите текст ссылки", - "linkmodal.text.required": "Текст обязателен", "linkmodal.title": "Вставить ссылку", - "linkmodal.url.invalid": "Пожалуйста, введите действительный URL-адрес", "linkmodal.url.label": "", "linkmodal.url.placeholder": "", - "linkmodal.url.required": "URL-адрес обязателен", "menu.administration": "Администрирование", "menu.mysettings": "Мой аккаунт", "menu.signout": "Выйти", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "Не удалось найти пользователей с <0>{0}.", "modal.showvotes.query.placeholder": "Найдите пользователей по их имени...", "modal.signin.header": "Оставьте свой отзыв", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "Прочитанные за 30 дней.", "mynotifications.message.nounread": "Нет непрочитанных уведомлений.", "mynotifications.page.subtitle": "Будьте в курсе того, что здесь происходит", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "Электронная почта", "mysettings.notification.channelweb": "Сайт", "mysettings.notification.event.discussion": "Обсуждения", - "mysettings.notification.event.discussion.staff": "комментариях всех постов, если вы не отпишитесь от них", - "mysettings.notification.event.discussion.visitors": "комментариях постов, на которые вы подписаны", "mysettings.notification.event.mention": "Упоминания", "mysettings.notification.event.newpost": "Новые посты", - "mysettings.notification.event.newpost.staff": "новых постах на этом сайте", - "mysettings.notification.event.newpost.visitors": "новых постах на этом сайте", "mysettings.notification.event.newpostcreated": "Ваша идея добавлена ​​👍", "mysettings.notification.event.statuschanged": "Изменения статуса", - "mysettings.notification.event.statuschanged.staff": "изменениях статуса всех постов, если вы не отпишитесь от них", - "mysettings.notification.event.statuschanged.visitors": "изменениях статуса постов, на которые вы подписаны", - "mysettings.notification.message.emailonly": "Вы будете получать уведомления по <0>электронной почте о {about}.", - "mysettings.notification.message.none": "Вы <0>НЕ будете получать уведомления об этих событиях.", - "mysettings.notification.message.webandemail": "Вы будете получать уведомления на <0>сайте и по <1>электронной почте о {about}.", - "mysettings.notification.message.webonly": "Вы будете получать уведомления на <0>сайте о {about}.", "mysettings.notification.title": "Выберите события, о которых вы хотите получать уведомления", "mysettings.page.subtitle": "Управление настройками вашего профиля", "mysettings.page.title": "Настройки", - "newpost.modal.addimage": "Добавить изображения", "newpost.modal.description.placeholder": "Расскажите нам об этом. Объясните подробно, не сдерживайтесь, чем больше информации, тем лучше.", "newpost.modal.submit": "Предложите свою идею", "newpost.modal.title": "Поделитесь своей идеей...", "newpost.modal.title.label": "Дайте название своей идее", "newpost.modal.title.placeholder": "Что-то короткое и емкое, изложите это в нескольких словах.", "page.backhome": "Верните меня на домашнюю страницу <0>{0}.", - "page.notinvited.text": "Мы не смогли найти аккаунт, соответствующий этому адресу электронной почты.", - "page.notinvited.title": "Не приглашён", "page.pendingactivation.didntreceive": "Не получили письмо?", "page.pendingactivation.resend": "Повторно отправить письмо с подтверждением", "page.pendingactivation.resending": "Повторная отправка...", "page.pendingactivation.text": "Мы отправили вам письмо со ссылкой для активации этого сайта.", "page.pendingactivation.text2": "Проверьте свою почту, чтобы продолжить.", "page.pendingactivation.title": "Ваш аккаунт ожидает подтверждения", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "Не удалось скопировать ссылку на комментарий, пожалуйста скопируйте URL страницы", "showpost.comment.copylink.success": "Ссылка на комментарий скопирована в буфер", "showpost.comment.unknownhighlighted": "Некорректный ID комментария #{id}", "showpost.commentinput.placeholder": "Оставить комментарий", "showpost.copylink.success": "Ссылка скопирована в буфер обмена", - "showpost.discussionpanel.emptymessage": "Комментариев нет.", - "showpost.label.author": "Создал <0/> · <1/>", "showpost.message.nodescription": "Описания нет.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "Это действие <0>нельзя отменить.", "showpost.moderationpanel.text.placeholder": "Почему вы удаляете этот пост? (необязательно)", - "showpost.mostwanted.comments": "{count, plural, one {# comment} other {# comments}}", - "showpost.mostwanted.votes": "{count, plural, one {# votes} other {# votes}}", "showpost.notificationspanel.message.subscribed": "Вы подписаны на уведомления об активности в этом посте.", "showpost.notificationspanel.message.unsubscribed": "Вы не подписаны на уведомления об активности в этом посте.", "showpost.postsearch.numofvotes": "{0} голосов", "showpost.postsearch.query.placeholder": "Выберите оригинальный пост...", - "showpost.response.date": "Статус изменен на {status} на {statusDate}", "showpost.responseform.message.mergedvotes": "Голоса этого поста будут прибавлены к голосам оригинального поста.", "showpost.responseform.text.placeholder": "Что произойдёт с этим предложением? Дайте людям знать о ваших планах...", "showpost.votespanel.more": "и ещё {extraVotesCount}", @@ -208,7 +216,6 @@ "signin.code.instruction": "Пожалуйста, введите код, который мы только что отправили на номер <0>{email}", "signin.code.placeholder": "Введите код здесь", "signin.code.sent": "Новый код был отправлен на вашу электронную почту.", - "signin.code.submit": "Представлять на рассмотрение", "signin.email.placeholder": "Адрес электронной почты", "signin.message.email": "Продолжить с электронной почтой", "signin.message.emaildisabled": "Аутентификация по адресу электронной почты отключена. <0>Я администратор и мне нужно обойти это ограничение.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "Войти с помощью", "signin.name.placeholder": "Ваше имя", "validation.custom.maxattachments": "Разрешено максимум {number} вложений.", - "validation.custom.maximagesize": "Размер изображения должен быть меньше {kilobytes}КБ.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "signin.code.expired": "Срок действия этого кода истёк или он уже был использован. Запросите новый.", - "signin.code.invalid": "Введённый вами код недействителен. Попробуйте ещё раз." + "validation.custom.maximagesize": "Размер изображения должен быть меньше {kilobytes}КБ." } \ No newline at end of file diff --git a/locale/sk/client.json b/locale/sk/client.json index 315593733..2e27699c6 100644 --- a/locale/sk/client.json +++ b/locale/sk/client.json @@ -4,13 +4,15 @@ "action.close": "Zavrieť", "action.commentsfeed": "Kanál komentárov", "action.confirm": "Potvrdiť", - "action.continue": "Pokračovať", "action.copylink": "Kopírovať odkaz", "action.delete": "Vymazať", + "action.delete.block": "", "action.edit": "Upraviť", "action.markallasread": "Označiť všetko ako prečítané", "action.ok": "V poriadku", "action.postsfeed": "Kanál príspevkov", + "action.publish": "", + "action.publish.verify": "", "action.respond": "Odpovedať", "action.save": "Uložiť", "action.signin": "Prihlásiť sa", @@ -18,7 +20,6 @@ "action.submit": "Potvrdiť", "action.vote": "Hlasovať za tento nápad", "action.voted": "Zahlasované!", - "d41FkJ": "{count, plural, one {# značka} few {# značky} other {# značiek}}", "editor.markdownmode": "Prepnúť na markdown editor", "editor.richtextmode": "Prepnúť na bohatý textový editor", "enum.poststatus.completed": "Dokončené", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "Radi by sme počuli, čo máte na mysli.\n\nČo môžeme urobiť lepšie? Tu môžete hlasovať, diskutovať a zdieľať nápady.", "home.lonely.suggestion": "Pred zdieľaním tejto stránky tu odporúčame vytvoriť <0> aspoň 3 návrhy. Počiatočný obsah je dôležitý, aby ste mohli zaujať publikum.", "home.lonely.text": "Zatiaľ neboli vytvorené žiadne príspevky.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "Vlastniť", "home.postfilter.label.status": "Stav", - "home.postfilter.label.view": "Prehľad", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "Najviac diskutované", "home.postfilter.option.mostwanted": "Najhľadanejšie", "home.postfilter.option.myposts": "Moje príspevky", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "Neoznačené", "home.postfilter.option.recent": "Nedávne", "home.postfilter.option.trending": "Trendy", - "home.postinput.description.placeholder": "Popíšte svoj návrh (voliteľné)", "home.postscontainer.label.noresults": "Vášmu vyhľadávaniu nezodpovedajú žiadne výsledky, skúste niečo iné.", "home.postscontainer.label.viewmore": "Zobraziť ďalšie príspevky", "home.postscontainer.query.placeholder": "Hľadať", "home.postsort.label": "Zoradiť podľa:", - "home.similar.subtitle": "Zvážte namiesto toho hlasovanie o existujúcich príspevkoch.", "home.similar.title": "Podobné príspevky", - "home.tagsfilter.label.with": "s", - "home.tagsfilter.selected.none": "Ľubovoľná značka", - "label.actions": "Akcie", "label.addtags": "Pridať značky...", "label.avatar": "Avatar", "label.custom": "Vlastné", - "label.description": "Popis", "label.discussion": "Diskusia", "label.edittags": "Upraviť značky", "label.email": "E-mail", @@ -77,36 +74,27 @@ "label.following": "Sledované", "label.gravatar": "Gravatar", "label.letter": "Písmeno", - "label.moderation": "Správca", "label.name": "Meno", "label.none": "Žiadne", - "label.notagsavailable": "Značky nie sú dostupné", - "label.notagsselected": "Žiadne vybrané značky", "label.notifications": "Notifikácie", "label.or": "ALEBO", "label.searchtags": "Hľadať značky...", - "label.selecttags": "Vybrať značky...", "label.subscribe": "Prihlásiť sa k odberu", "label.tags": "Značky", - "label.unfollow": "Prestať sledovať", "label.unread": "Neprečítané", "label.unsubscribe": "Zrušiť odber", "label.voters": "Hlasujúci", "labels.notagsavailable": "Značky nie sú dostupné", - "labels.notagsselected": "Žiadne vybrané značky", "legal.agreement": "Prečítal som a súhlasím s <0/> a <1/>.", - "legal.notice": "Prihlásením súhlasíte s <2/><0/> a <1/>.", + "legal.notice": "Prihlásením súhlasíte s <0/><1/> a <2/>.", "legal.privacypolicy": "Zásady ochrany osobných údajov", "legal.termsofservice": "Podmienky služby", "linkmodal.insert": "Vložiť odkaz", "linkmodal.text.label": "Text na zobrazenie", "linkmodal.text.placeholder": "Zadajte text odkazu", - "linkmodal.text.required": "Vyžaduje sa text", "linkmodal.title": "Vložiť odkaz", - "linkmodal.url.invalid": "Zadajte platnú URL adresu", "linkmodal.url.label": "", "linkmodal.url.placeholder": "", - "linkmodal.url.required": "URL adresa je povinná", "menu.administration": "Administrácia", "menu.mysettings": "Moje nastavenia", "menu.signout": "Odhlásiť sa", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "Nenašli sa žiadni používatelia <0>{0}.", "modal.showvotes.query.placeholder": "Vyhľadajte používateľov podľa mena...", "modal.signin.header": "Odoslať spätnú väzbu", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "Prečítajte si posledných 30 dní.", "mynotifications.message.nounread": "Žiadne neprečítané upozornenia.", "mynotifications.page.subtitle": "Majte prehľad o tom, čo sa deje", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "E-mail", "mysettings.notification.channelweb": "Web", "mysettings.notification.event.discussion": "Diskusia", - "mysettings.notification.event.discussion.staff": "komentáre k všetkým príspevkom, pokiaľ nie sú jednotlivo odhlásení", - "mysettings.notification.event.discussion.visitors": "komentáre k príspevkom, na ktorých odber ste sa prihlásili", "mysettings.notification.event.mention": "Zmienky", "mysettings.notification.event.newpost": "Nový príspevok", - "mysettings.notification.event.newpost.staff": "nové príspevky na tomto webe", - "mysettings.notification.event.newpost.visitors": "nové príspevky na tomto webe", "mysettings.notification.event.newpostcreated": "Váš nápad bol pridaný 👍", "mysettings.notification.event.statuschanged": "Stav zmenený", - "mysettings.notification.event.statuschanged.staff": "zmena stavu všetkých príspevkov, pokiaľ nie sú jednotlivo odhlásené", - "mysettings.notification.event.statuschanged.visitors": "zmena stavu v príspevkoch, na odber ktorých ste sa prihlásili", - "mysettings.notification.message.emailonly": "Budete dostávať <0>emailové upozornenia na adresu {about}.", - "mysettings.notification.message.none": "<0>NEBUDETE dostávať žiadne upozornenie na túto udalosť.", - "mysettings.notification.message.webandemail": "Budete dostávať <0>webové a <1>emailové oznámenia o {about}.", - "mysettings.notification.message.webonly": "Budete dostávať <0>webové upozornenia o {about}.", "mysettings.notification.title": "Na nasledujúcom paneli vyberte, na ktoré udalosti chcete dostávať upozornenia", "mysettings.page.subtitle": "Spravujte nastavenia svojho profilu", "mysettings.page.title": "Nastavenie", - "newpost.modal.addimage": "Pridať obrázky", "newpost.modal.description.placeholder": "Povedzte nám o tom. Vysvetlite to podrobne, nezdržujte sa, čím viac informácií, tým lepšie.", "newpost.modal.submit": "Odošlite svoj nápad", "newpost.modal.title": "Podeľte sa o svoj nápad...", "newpost.modal.title.label": "Dajte svojmu nápadu názov", "newpost.modal.title.placeholder": "Niečo krátke a výstižné, zhrňte to pár slovami", "page.backhome": "Vezmi ma späť do <0>{0} domovskú stránku.", - "page.notinvited.text": "Nepodarilo sa nám nájsť účet pre vašu emailovú adresu.", - "page.notinvited.title": "Nepozvaný", "page.pendingactivation.didntreceive": "Nedostali ste e-mail?", "page.pendingactivation.resend": "Znova odoslať overovací e-mail", "page.pendingactivation.resending": "Znovu sa odosiela...", "page.pendingactivation.text": "Poslali sme vám potvrdzovací email s odkazom na aktiváciu vašich stránok.", "page.pendingactivation.text2": "Aktivujte prosím svoju doručenú poštu.", "page.pendingactivation.title": "Váš účet čaká na aktiváciu", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "Nepodarilo sa skopírovať odkaz na komentár, prosím skopírujte URL adresu stránky", "showpost.comment.copylink.success": "Odkaz na komentár skopírovaný do schránky", "showpost.comment.unknownhighlighted": "Neplatné ID komentára #{id}", "showpost.commentinput.placeholder": "Zanechať komentár", "showpost.copylink.success": "Odkaz skopírovaný do schránky", - "showpost.discussionpanel.emptymessage": "Zatiaľ sa nikto nevyjadril.", - "showpost.label.author": "Pridané <0/> · <1/>", "showpost.message.nodescription": "Nie je poskytnutý žiadny popis.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "Túto operáciu <0>nemožno vrátiť späť.", "showpost.moderationpanel.text.placeholder": "Prečo mažete tento príspevok? (voliteľné)", - "showpost.mostwanted.comments": "{count, plural, one {# komentár} few {# komentáre} other {# komentárov}}", - "showpost.mostwanted.votes": "{count, plural, one {# hlas} few {# hlasy} other {# hlasov}}", "showpost.notificationspanel.message.subscribed": "Dostávate upozornenia na aktivitu v tomto príspevku.", "showpost.notificationspanel.message.unsubscribed": "Na tento príspevok nedostanete žiadne upozornenie.", "showpost.postsearch.numofvotes": "{0} hlasov", "showpost.postsearch.query.placeholder": "Hľadať pôvodný príspevok...", - "showpost.response.date": "Stav zmenený dňa {statusDate} na {status}", "showpost.responseform.message.mergedvotes": "Hlasy z tohto príspevku budú zlúčené do pôvodného príspevku.", "showpost.responseform.text.placeholder": "Čo sa deje s týmto príspevkom? Dajte svojim používateľom vedieť, aké máte plány...", "showpost.votespanel.more": "+{extraVotesCount} viac", @@ -208,7 +216,6 @@ "signin.code.instruction": "Zadajte kód, ktorý sme práve poslali na adresu <0>{email}", "signin.code.placeholder": "Sem zadajte kód", "signin.code.sent": "Nový kód bol odoslaný na váš e-mail.", - "signin.code.submit": "Odoslať", "signin.email.placeholder": "Emailová adresa", "signin.message.email": "Pokračovať pomocou e-mailu", "signin.message.emaildisabled": "Správca zakázal overovanie emailu. Ak máte účet správcu a potrebujete obísť toto obmedzenie, <0>kliknite sem.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "Prihlásiť sa pomocou", "signin.name.placeholder": "Vaše meno", "validation.custom.maxattachments": "Maximálny počet príloh je {number}.", - "validation.custom.maximagesize": "Veľkosť obrázka musí byť menšia ako {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "signin.code.expired": "Platnosť tohto kódu vypršala alebo už bol použitý. Požiadajte o nový.", - "signin.code.invalid": "Zadaný kód je neplatný. Skúste to znova." + "validation.custom.maximagesize": "Veľkosť obrázka musí byť menšia ako {kilobytes}KB." } \ No newline at end of file diff --git a/locale/sv-SE/client.json b/locale/sv-SE/client.json index ee37f3784..b6b9288a1 100644 --- a/locale/sv-SE/client.json +++ b/locale/sv-SE/client.json @@ -4,13 +4,15 @@ "action.close": "Stäng", "action.commentsfeed": "Kommentarflöde", "action.confirm": "Bekräfta", - "action.continue": "Fortsätta", "action.copylink": "Kopiera länk", "action.delete": "Radera", + "action.delete.block": "", "action.edit": "Ändra", "action.markallasread": "Markera alla som lästa", "action.ok": "OK", "action.postsfeed": "Inläggsflöde", + "action.publish": "", + "action.publish.verify": "", "action.respond": "Svara", "action.save": "Spara", "action.signin": "Logga in", @@ -18,7 +20,6 @@ "action.submit": "Skicka", "action.vote": "Rösta på den här idén", "action.voted": "Röstade!", - "d41FkJ": "{count, plural, one {# tag} other {# tags}}", "editor.markdownmode": "Växla till markdown-redigeraren", "editor.richtextmode": "Växla till RTF-redigerare", "enum.poststatus.completed": "Avslutad", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "Vi vill gärna höra vad du tänker på.\n\nVad kan vi göra bättre? Detta är platsen för dig att rösta, diskutera och dela idéer.", "home.lonely.suggestion": "Vi rekommenderar att du skapar <0>minst 3 förslag här innan du börjar dela den här webbplatsen. Det initiala innehållet är viktigt för att engagera dina besökare.", "home.lonely.text": "Det finns inte några inlägg ännu.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "Egen", "home.postfilter.label.status": "", - "home.postfilter.label.view": "Visa", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "Mest diskuterade", "home.postfilter.option.mostwanted": "Mest önskade", "home.postfilter.option.myposts": "Mina inlägg", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "Otaggad", "home.postfilter.option.recent": "Senaste", "home.postfilter.option.trending": "Trendar", - "home.postinput.description.placeholder": "Beskriv ditt förslag (frivilligt)", "home.postscontainer.label.noresults": "Inga resultat matchar din sökning, prova något annat.", "home.postscontainer.label.viewmore": "Visa fler inlägg", "home.postscontainer.query.placeholder": "Sök", "home.postsort.label": "Sortera efter:", - "home.similar.subtitle": "Överväg att rösta på befintliga inlägg i stället.", "home.similar.title": "Liknande inlägg", - "home.tagsfilter.label.with": "med", - "home.tagsfilter.selected.none": "Alla etiketter", - "label.actions": "Åtgärder", "label.addtags": "Lägg till taggar...", "label.avatar": "Profilbild", "label.custom": "Anpassad", - "label.description": "Beskrivning", "label.discussion": "Diskussion", "label.edittags": "Redigera taggar", "label.email": "E-post", @@ -77,36 +74,27 @@ "label.following": "Följande", "label.gravatar": "Gravatar", "label.letter": "Bokstav", - "label.moderation": "Moderering", "label.name": "Namn", "label.none": "Ingen", - "label.notagsavailable": "Inga taggar tillgängliga", - "label.notagsselected": "Inga taggar valda", "label.notifications": "Aviseringar", "label.or": "ELLER", "label.searchtags": "Sök efter taggar...", - "label.selecttags": "Välj taggar...", "label.subscribe": "Prenumerera", "label.tags": "Etiketter", - "label.unfollow": "Sluta följa", "label.unread": "Olästa", "label.unsubscribe": "Avsluta prenumeration", "label.voters": "Röstande", "labels.notagsavailable": "Inga taggar tillgängliga", - "labels.notagsselected": "Inga taggar valda", "legal.agreement": "Jag har läst och godkänner <0/> och <1/>.", - "legal.notice": "Genom att logga in godkänner du <2/><0/> och <1/>.", + "legal.notice": "Genom att logga in godkänner du <0/><1/> och <2/>.", "legal.privacypolicy": "Integritetspolicy", "legal.termsofservice": "Användarvillkor", "linkmodal.insert": "Infoga länk", "linkmodal.text.label": "Text att visa", "linkmodal.text.placeholder": "Ange länktext", - "linkmodal.text.required": "Text krävs", "linkmodal.title": "Infoga länk", - "linkmodal.url.invalid": "Ange en giltig URL", "linkmodal.url.label": "", "linkmodal.url.placeholder": "", - "linkmodal.url.required": "URL krävs", "menu.administration": "Administration", "menu.mysettings": "Mina Inställningar", "menu.signout": "Logga ut", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "Inga användare hittades som matchar <0>{0}.", "modal.showvotes.query.placeholder": "Sök efter användare med namn...", "modal.signin.header": "Skicka in din feedback", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "Läst de senaste 30 dagarna.", "mynotifications.message.nounread": "Inga olästa aviseringar.", "mynotifications.page.subtitle": "Håll dig uppdaterad om vad som händer", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "E-post", "mysettings.notification.channelweb": "Webb", "mysettings.notification.event.discussion": "Diskussion", - "mysettings.notification.event.discussion.staff": "kommentarer på alla inlägg om inte individuellt avanmält", - "mysettings.notification.event.discussion.visitors": "kommentarer på inlägg som du prenumererar på", "mysettings.notification.event.mention": "Omnämnanden", "mysettings.notification.event.newpost": "Nytt inlägg", - "mysettings.notification.event.newpost.staff": "nya inlägg på den här webbplatsen", - "mysettings.notification.event.newpost.visitors": "nya inlägg på den här webbplatsen", "mysettings.notification.event.newpostcreated": "Din idé har lagts till 👍", "mysettings.notification.event.statuschanged": "Status ändrad", - "mysettings.notification.event.statuschanged.staff": "statusändring på alla inlägg om inte individuellt avanmäld", - "mysettings.notification.event.statuschanged.visitors": "statusändring på inlägg som du har prenumererat på", - "mysettings.notification.message.emailonly": "Du kommer att få <0>e-post om {about}.", - "mysettings.notification.message.none": "Du kommer <0>INTE att få någon avisering om denna händelse.", - "mysettings.notification.message.webandemail": "Du kommer att få avisering på <0>web och <1>e-post om {about}.", - "mysettings.notification.message.webonly": "Du kommer att få avisering på <0>web om {about}.", "mysettings.notification.title": "Använd följande panel för att välja vilka händelser du vill få aviseringar om", "mysettings.page.subtitle": "Hantera dina profilinställningar", "mysettings.page.title": "Inställningar", - "newpost.modal.addimage": "Lägg till bilder", "newpost.modal.description.placeholder": "Berätta om det. Förklara det utförligt, tveka inte, ju mer information desto bättre.", "newpost.modal.submit": "Skicka in din idé", "newpost.modal.title": "Dela din idé...", "newpost.modal.title.label": "Ge din idé en titel", "newpost.modal.title.placeholder": "Något kort och rappt, sammanfatta det med några få ord", "page.backhome": "Ta mig tillbaka till <0>{0} hemsida.", - "page.notinvited.text": "Vi kunde inte hitta ett konto för din e-postadress.", - "page.notinvited.title": "Inte inbjuden", "page.pendingactivation.didntreceive": "Fick du inte e-postmeddelandet?", "page.pendingactivation.resend": "Skicka verifieringsmejlet igen", "page.pendingactivation.resending": "Skickar om...", "page.pendingactivation.text": "Vi har skickat ett bekräftelsemail med en länk för att aktivera din webbplats.", "page.pendingactivation.text2": "Kontrollera din inkorg för att aktivera den.", "page.pendingactivation.title": "Ditt konto väntar på aktivering", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "Misslyckades med att kopiera kommentarslänken, kopiera sidans URL", "showpost.comment.copylink.success": "Kommentarlänk kopierad till urklipp", "showpost.comment.unknownhighlighted": "Ogiltigt kommentar-ID #{id}", "showpost.commentinput.placeholder": "Skriv en kommentar", "showpost.copylink.success": "Länk kopierad till urklipp", - "showpost.discussionpanel.emptymessage": "Ingen har kommenterat ännu.", - "showpost.label.author": "Skriven av <0/> · <1/>", "showpost.message.nodescription": "Ingen beskrivning angiven.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "Denna åtgärd <0>går inte att ångra.", "showpost.moderationpanel.text.placeholder": "Varför tar du bort detta inlägg? (valfritt)", - "showpost.mostwanted.comments": "{count, plural, one {# comment} other {# comments}}", - "showpost.mostwanted.votes": "{count, plural, one {# votes} other {# votes}}", "showpost.notificationspanel.message.subscribed": "Du får aviseringar om aktivitet på det här inlägget.", "showpost.notificationspanel.message.unsubscribed": "Du får inte aviseringar för det här inlägget.", "showpost.postsearch.numofvotes": "{0} röster", "showpost.postsearch.query.placeholder": "Sök i ursprungliga inlägget...", - "showpost.response.date": "Status ändrades till {status} den {statusDate}", "showpost.responseform.message.mergedvotes": "Röster från det här inlägget kommer att flyttas till det ursprungliga inlägget.", "showpost.responseform.text.placeholder": "Vad händer med det här inlägget? Låt dina användare veta vad du planerar...", "showpost.votespanel.more": "+{extraVotesCount} ytterligare", @@ -208,7 +216,6 @@ "signin.code.instruction": "Vänligen skriv in koden vi just skickade till <0>{email}", "signin.code.placeholder": "Skriv in koden här", "signin.code.sent": "En ny kod har skickats till din e-postadress.", - "signin.code.submit": "Överlämna", "signin.email.placeholder": "E-postadress", "signin.message.email": "Fortsätt med e-post", "signin.message.emaildisabled": "E-postautentisering har inaktiverats av en administratör. Om du har ett administratörskonto och behöver kringgå denna begränsning, <0>klicka här.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "Logga in med", "signin.name.placeholder": "Ditt namn", "validation.custom.maxattachments": "Maximalt {number} bilagor är tillåtna.", - "validation.custom.maximagesize": "Bildstorleken måste vara mindre än {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, =1 {# etikett} other {# etiketter}}", - "signin.code.expired": "Denna kod har gått ut eller har redan använts. Vänligen begär en ny.", - "signin.code.invalid": "Koden du angav är ogiltig. Försök igen." + "validation.custom.maximagesize": "Bildstorleken måste vara mindre än {kilobytes}KB." } \ No newline at end of file diff --git a/locale/tr/client.json b/locale/tr/client.json index b76f1ec1c..26d0a7437 100644 --- a/locale/tr/client.json +++ b/locale/tr/client.json @@ -4,13 +4,15 @@ "action.close": "Kapat", "action.commentsfeed": "Yorum Beslemesi", "action.confirm": "Onayla", - "action.continue": "Devam etmek", "action.copylink": "Bağlantıyı kopyala", "action.delete": "Sil", + "action.delete.block": "", "action.edit": "Düzenle", "action.markallasread": "Tümünü Okundu olarak işaretle", "action.ok": "Tamam", "action.postsfeed": "Gönderi Beslemesi", + "action.publish": "", + "action.publish.verify": "", "action.respond": "Yanıtla", "action.save": "Kaydet", "action.signin": "Giriş Yap", @@ -18,7 +20,6 @@ "action.submit": "Gönder", "action.vote": "Bu fikre oy verin", "action.voted": "Oy verildi!", - "d41FkJ": "{count, plural, one {# tag} other {# tags}}", "editor.markdownmode": "Markdown düzenleyicisine geç", "editor.richtextmode": "Zengin metin düzenleyicisine geç", "enum.poststatus.completed": "Tamamlandı", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "Ne düşündüğünüzü öğrenmeyi çok isteriz.\n\nKendimizi nasıl geliştirebiliriz? Burada önerileri oylayabilir, tartışabilir ya da paylaşabilirsiniz.", "home.lonely.suggestion": "Siteyi paylaşmadan önce buraya <0>en az 3 öneri girmenizi tavsiye ederiz. İlk içerikler, hedef kitlenizin etkileşime geçmesi için önemlidir.", "home.lonely.text": "Henüz hiç öneri oluşturulmadı.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "Sahip olmak", "home.postfilter.label.status": "Durum", - "home.postfilter.label.view": "Görünüm", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "En Tartışılan", "home.postfilter.option.mostwanted": "En Talep Edilen", "home.postfilter.option.myposts": "Yazılarım", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "Etiketsiz", "home.postfilter.option.recent": "En Son", "home.postfilter.option.trending": "Öne Çıkanlar", - "home.postinput.description.placeholder": "Önerinizin detaylarını açıklayın (isteğe bağlı)", "home.postscontainer.label.noresults": "Aramanız bir sonuç vermedi, başka bir aramayı deneyin.", "home.postscontainer.label.viewmore": "Daha çok öneri görüntüle", "home.postscontainer.query.placeholder": "Arama", "home.postsort.label": "Göre sırala:", - "home.similar.subtitle": "Yeni eklemek yerine benzer önerilere oy vermeyi düşünür müsünüz?", "home.similar.title": "Benzer öneriler", - "home.tagsfilter.label.with": "birlikte", - "home.tagsfilter.selected.none": "Herhangi bir etiket", - "label.actions": "İşlemler", "label.addtags": "Etiket ekle...", "label.avatar": "Avatar", "label.custom": "Özel", - "label.description": "Açıklama", "label.discussion": "Tartışma", "label.edittags": "Etiketleri düzenle", "label.email": "E-Posta", @@ -77,36 +74,27 @@ "label.following": "Takip etme", "label.gravatar": "Gravatar", "label.letter": "Harf", - "label.moderation": "Moderasyon", "label.name": "İsim", "label.none": "Hiçbiri", - "label.notagsavailable": "Hiçbir etiket mevcut değil", - "label.notagsselected": "Hiçbir etiket seçilmedi", "label.notifications": "Bildirimler", "label.or": "VEYA", "label.searchtags": "Etiketleri ara...", - "label.selecttags": "Etiketleri seçin...", "label.subscribe": "Abone ol", "label.tags": "Etiketler", - "label.unfollow": "Takipten çık", "label.unread": "Okunmamış", "label.unsubscribe": "Abonelikten çık", "label.voters": "Oy verenler", "labels.notagsavailable": "Hiçbir etiket mevcut değil", - "labels.notagsselected": "Hiçbir etiket seçilmedi", "legal.agreement": "<0/> ve <1/> okudum ve onayladım.", - "legal.notice": "Giriş yaparak <2/><0/> ve <1/> maddelerini kabul etmiş olursunuz.", + "legal.notice": "Giriş yaparak <0/><1/> ve <2/> maddelerini kabul etmiş olursunuz.", "legal.privacypolicy": "Gizlilik Politikası", "legal.termsofservice": "Hizmet Koşulları", "linkmodal.insert": "Bağlantı Ekle", "linkmodal.text.label": "Görüntülenecek metin", "linkmodal.text.placeholder": "Bağlantı metnini girin", - "linkmodal.text.required": "Metin gereklidir", "linkmodal.title": "Bağlantı Ekle", - "linkmodal.url.invalid": "Lütfen geçerli bir URL girin", "linkmodal.url.label": "", "linkmodal.url.placeholder": "", - "linkmodal.url.required": "URL gerekli", "menu.administration": "Yönetim", "menu.mysettings": "Ayarlarım", "menu.signout": "Çıkış yap", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "Eşleşen kullanıcı bulunamadı <0>{0}.", "modal.showvotes.query.placeholder": "Kullanıcıları ismiyle arayın...", "modal.signin.header": "Geri bildiriminizi gönderin", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "Son 30 gün içinde okunmuş.", "mynotifications.message.nounread": "Okunmamış bildirim yok.", "mynotifications.page.subtitle": "Neler olduğundan haberdar olun", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "E-Posta", "mysettings.notification.channelweb": "Web", "mysettings.notification.event.discussion": "Tartışma", - "mysettings.notification.event.discussion.staff": "teker teker iptal edilmedikçe tüm önerilerdeki yorumlar", - "mysettings.notification.event.discussion.visitors": "abone olduğunuz önerilerdeki yorumlar", "mysettings.notification.event.mention": "Bahsedilenler", "mysettings.notification.event.newpost": "Yeni Öneri", - "mysettings.notification.event.newpost.staff": "bu sayfaya ait yeni öneriler", - "mysettings.notification.event.newpost.visitors": "bu sayfaya ait yeni öneriler", "mysettings.notification.event.newpostcreated": "Fikriniz eklendi 👍", "mysettings.notification.event.statuschanged": "Durum Değişti", - "mysettings.notification.event.statuschanged.staff": "teker teker iptal edilmedikçe tüm önerilerdeki durum değişiklikleri", - "mysettings.notification.event.statuschanged.visitors": "abone olduğunuz önerilerdeki durum değişiklikleri", - "mysettings.notification.message.emailonly": "{about} hakkında <0>e-posta bildirimi alacaksınız.", - "mysettings.notification.message.none": "Bu olay hakkında <0>HİÇ bildirim almayacaksınız.", - "mysettings.notification.message.webandemail": "{about} hakkında <0>web ve <0>e-posta bildirimi alacaksınız.", - "mysettings.notification.message.webonly": "{about} hakkında <0>web bildirimi alacaksınız.", "mysettings.notification.title": "Aşağıdaki panelden hangi olaylar hakkında bildirim almak istediğinizi seçin", "mysettings.page.subtitle": "Profil ayarlarınızı yönetin", "mysettings.page.title": "Ayarlar", - "newpost.modal.addimage": "Resim Ekle", "newpost.modal.description.placeholder": "Bize anlatın. Tam olarak açıklayın, saklamayın, ne kadar çok bilgi o kadar iyi.", "newpost.modal.submit": "Fikrinizi gönderin", "newpost.modal.title": "Fikrinizi paylaşın...", "newpost.modal.title.label": "Fikrinize bir başlık verin", "newpost.modal.title.placeholder": "Kısa ve öz bir şey, birkaç kelimeyle özetleyin", "page.backhome": "Beni <0>{0} ana sayfaya götür.", - "page.notinvited.text": "E-posta adresinizle ilişkili bir hesap bulamadık.", - "page.notinvited.title": "Davet edilmemiş", "page.pendingactivation.didntreceive": "E-postayı almadınız mı?", "page.pendingactivation.resend": "Doğrulama e-postasını yeniden gönder", "page.pendingactivation.resending": "Tekrar gönderiliyor...", "page.pendingactivation.text": "Size, sayfanız için aktivasyon bağlantısı barındıran bir onay e-postası gönderdik.", "page.pendingactivation.text2": "Aktivasyon için lütfen gelen kutunuzu kontrol edin.", "page.pendingactivation.title": "Hesabınız aktivasyon beklemektedir", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "Yorum bağlantısı kopyalanamadı, lütfen sayfa URL'sini kopyalayın", "showpost.comment.copylink.success": "Yorum bağlantısı panoya kopyalandı", "showpost.comment.unknownhighlighted": "Geçersiz yorum kimliği #{id}", "showpost.commentinput.placeholder": "Yorum yazın", "showpost.copylink.success": "Bağlantı panoya kopyalandı", - "showpost.discussionpanel.emptymessage": "Henüz hiç kimse yorum yapmadı.", - "showpost.label.author": "<0/> · <1/> tarafından gönderildi", "showpost.message.nodescription": "Herhangi bir açıklama belirtilmedi.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "Bu işlem geri <0>alınamaz.", "showpost.moderationpanel.text.placeholder": "Bu öneriyi neden siliyorsunuz? (isteğe bağlı)", - "showpost.mostwanted.comments": "{count, plural, one {# comment} other {# comments}}", - "showpost.mostwanted.votes": "{count, plural, one {# votes} other {# votes}}", "showpost.notificationspanel.message.subscribed": "Bu öneri üzerindeki hareketler hakkında bildirimler alacaksınız.", "showpost.notificationspanel.message.unsubscribed": "Bu öneri hakkında bildirim almayacaksınız.", "showpost.postsearch.numofvotes": "{0} oy", "showpost.postsearch.query.placeholder": "Orijinal öneri ara...", - "showpost.response.date": "Durum {statusDate} için {status} olarak değiştirildi", "showpost.responseform.message.mergedvotes": "Bu önerideki yorumlar orijinal öneriye dahil edilecek.", "showpost.responseform.text.placeholder": "Bu öneriye neler oluyor? Kullanıcılara planlarınız hakkında bilgi verin...", "showpost.votespanel.more": "+{extraVotesCount} daha", @@ -208,7 +216,6 @@ "signin.code.instruction": "Lütfen az önce <0>{email} adresine gönderdiğimiz kodu yazın", "signin.code.placeholder": "Kodu buraya yazın", "signin.code.sent": "E-postanıza yeni bir kod gönderildi.", - "signin.code.submit": "Göndermek", "signin.email.placeholder": "E-posta adresi", "signin.message.email": "E-postayla devam et", "signin.message.emaildisabled": "E-posta doğrulaması bir yönetici tarafından devre dışı bırakıldı. Eğer bir yönetici hesabınız varsa ve bu kısıtı kaldırmak istiyorsanız <0>buraya tıklayın.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "İle giriş yapın", "signin.name.placeholder": "Adınız", "validation.custom.maxattachments": "En fazla {number} ek dosyaya izin verilir.", - "validation.custom.maximagesize": "Resim boyutu {kilobytes}KB'den küçük olmalıdır.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# etiket} other {# etiket}}", - "signin.code.expired": "Bu kodun süresi dolmuş veya daha önce kullanılmış. Lütfen yenisini talep edin.", - "signin.code.invalid": "Girdiğiniz kod geçersiz. Lütfen tekrar deneyin." + "validation.custom.maximagesize": "Resim boyutu {kilobytes}KB'den küçük olmalıdır." } \ No newline at end of file diff --git a/locale/zh-CN/client.json b/locale/zh-CN/client.json index 8f4769f6d..aa10dceaa 100644 --- a/locale/zh-CN/client.json +++ b/locale/zh-CN/client.json @@ -4,13 +4,15 @@ "action.close": "关闭", "action.commentsfeed": "评论提要", "action.confirm": "确认", - "action.continue": "继续", "action.copylink": "复制链接", "action.delete": "删除", + "action.delete.block": "", "action.edit": "编辑", "action.markallasread": "将全部标记为已读", "action.ok": "确定", "action.postsfeed": "帖子提要", + "action.publish": "", + "action.publish.verify": "", "action.respond": "回复/标记", "action.save": "保存", "action.signin": "登录", @@ -18,7 +20,6 @@ "action.submit": "提交", "action.vote": "投票支持这个想法", "action.voted": "已投票!", - "d41FkJ": "{count, plural, one {# tag} other {# tags}}", "editor.markdownmode": "切换到 Markdown 编辑器", "editor.richtextmode": "切换到富文本编辑器", "enum.poststatus.completed": "完成", @@ -46,9 +47,11 @@ "home.form.defaultwelcomemessage": "我们很想听听你在想什么。\n\n我们能做得更好吗?这是您投票、讨论和分享想法的地方.", "home.lonely.suggestion": "建议您在共享此网站之前,在此处创建<0>至少3个建议。初始内容对于开始吸引观众很重要.", "home.lonely.text": "尚未创建任何帖子.", + "home.postfilter.label.moderation": "", "home.postfilter.label.myactivity": "自己的", "home.postfilter.label.status": "地位", - "home.postfilter.label.view": "看板", + "home.postfilter.moderation.approved": "", + "home.postfilter.moderation.pending": "", "home.postfilter.option.mostdiscussed": "讨论最多", "home.postfilter.option.mostwanted": "投票最多", "home.postfilter.option.myposts": "我的帖子", @@ -56,20 +59,14 @@ "home.postfilter.option.notags": "未标记", "home.postfilter.option.recent": "最近", "home.postfilter.option.trending": "趋势", - "home.postinput.description.placeholder": "描述你的建议(可选)", "home.postscontainer.label.noresults": "没有与您的搜索匹配的结果,请尝试其他搜索.", "home.postscontainer.label.viewmore": "查看更多帖子", "home.postscontainer.query.placeholder": "搜索", "home.postsort.label": "排序方式:", - "home.similar.subtitle": "考虑对现有内容进行投票.", "home.similar.title": "类似帖子", - "home.tagsfilter.label.with": "关于", - "home.tagsfilter.selected.none": "任何标签", - "label.actions": "行动", "label.addtags": "添加标签...", "label.avatar": "阿凡达", "label.custom": "自定义", - "label.description": "说明", "label.discussion": "讨论", "label.edittags": "编辑标签", "label.email": "邮箱", @@ -77,36 +74,27 @@ "label.following": "下列的", "label.gravatar": "Gravatar", "label.letter": "稍后", - "label.moderation": "管理", "label.name": "名称", "label.none": "没有任何", - "label.notagsavailable": "没有可用的标签", - "label.notagsselected": "未选择标签", "label.notifications": "通知", "label.or": "或者", "label.searchtags": "搜索标签...", - "label.selecttags": "选择标签...", "label.subscribe": "订阅", "label.tags": "标签", - "label.unfollow": "取消关注", "label.unread": "未读", "label.unsubscribe": "取消订阅", "label.voters": "投票者", "labels.notagsavailable": "没有可用的标签", - "labels.notagsselected": "未选择标签", "legal.agreement": "我已阅读并同意<0/>和<1/>。", - "legal.notice": "在签署时,您同意<2/><0/>和<1/>。", + "legal.notice": "在签署时,您同意<0/><1/>和<2/>。", "legal.privacypolicy": "隐私政策", "legal.termsofservice": "服务条款", "linkmodal.insert": "插入链接", "linkmodal.text.label": "要显示的文本", "linkmodal.text.placeholder": "输入链接文本", - "linkmodal.text.required": "文本为必填项", "linkmodal.title": "插入链接", - "linkmodal.url.invalid": "请输入有效的 URL", "linkmodal.url.label": "网址", "linkmodal.url.placeholder": "", - "linkmodal.url.required": "需要 URL", "menu.administration": "管理", "menu.mysettings": "我的设置", "menu.signout": "退出", @@ -128,6 +116,26 @@ "modal.showvotes.message.zeromatches": "未找到匹配的用户 <0>{0}.", "modal.showvotes.query.placeholder": "按名称搜索用户...", "modal.signin.header": "提交您的反馈", + "moderation.comment.delete.block.error": "", + "moderation.comment.delete.error": "", + "moderation.comment.deleted": "", + "moderation.comment.deleted.blocked": "", + "moderation.comment.publish.error": "", + "moderation.comment.publish.verify.error": "", + "moderation.comment.published": "", + "moderation.comment.published.verified": "", + "moderation.empty": "", + "moderation.fetch.error": "", + "moderation.post.delete.block.error": "", + "moderation.post.delete.error": "", + "moderation.post.deleted": "", + "moderation.post.deleted.blocked": "", + "moderation.post.publish.error": "", + "moderation.post.publish.verify.error": "", + "moderation.post.published": "", + "moderation.post.published.verified": "", + "moderation.subtitle": "", + "moderation.title": "", "mynotifications.label.readrecently": "过去30天阅读.", "mynotifications.message.nounread": "无未读通知.", "mynotifications.page.subtitle": "及时了解正在发生的事情", @@ -150,55 +158,55 @@ "mysettings.notification.channelemail": "电子邮件", "mysettings.notification.channelweb": "网站", "mysettings.notification.event.discussion": "讨论", - "mysettings.notification.event.discussion.staff": "对所有帖子发表评论,除非单独取消订阅", - "mysettings.notification.event.discussion.visitors": "对您订阅的帖子的评论", "mysettings.notification.event.mention": "提及", "mysettings.notification.event.newpost": "新帖子", - "mysettings.notification.event.newpost.staff": "本网站上的新帖子", - "mysettings.notification.event.newpost.visitors": "本网站上的新帖子", "mysettings.notification.event.newpostcreated": "您的想法已添加👍", "mysettings.notification.event.statuschanged": "状态已更改", - "mysettings.notification.event.statuschanged.staff": "除非单独取消订阅,否则所有帖子的状态都会发生变化", - "mysettings.notification.event.statuschanged.visitors": "您订阅的帖子的状态更改", - "mysettings.notification.message.emailonly": "您将收到 <0>电子邮件 相关的通知 {about}.", - "mysettings.notification.message.none": "您将 <0>不会 收到有关此事件的任何通知.", - "mysettings.notification.message.webandemail": "您将收到关于以下内容的<0>网络和<1>电子邮件通知 {about}.", - "mysettings.notification.message.webonly": "您将收到有关以下内容的<0>网络通知 {about}.", "mysettings.notification.title": "使用以下面板选择要接收通知的事件", "mysettings.page.subtitle": "管理您的个人资料设置", "mysettings.page.title": "设置", - "newpost.modal.addimage": "添加图像", "newpost.modal.description.placeholder": "告诉我们吧。请完整解释,不要隐瞒,信息越多越好。", "newpost.modal.submit": "提交您的想法", "newpost.modal.title": "分享你的想法...", "newpost.modal.title.label": "给你的想法起个标题", "newpost.modal.title.placeholder": "简短而活泼,用几句话概括", "page.backhome": "带我回到<0>{0}主页.", - "page.notinvited.text": "我们找不到您电子邮件地址的帐户.", - "page.notinvited.title": "未被邀请", "page.pendingactivation.didntreceive": "没收到邮件?", "page.pendingactivation.resend": "重新发送验证邮件", "page.pendingactivation.resending": "重新发送……", "page.pendingactivation.text": "我们向您发送了一封确认电子邮件,其中包含激活您账户的网站链接.", "page.pendingactivation.text2": "请检查您的收件箱以激活它.", "page.pendingactivation.title": "您的帐户正在等待激活", + "pagination.next": "", + "pagination.prev": "", + "post.pending": "", "showpost.comment.copylink.error": "复制评论链接失败,请复制页面URL", "showpost.comment.copylink.success": "评论链接已复制到剪贴板", "showpost.comment.unknownhighlighted": "无效的评论ID #{id}", "showpost.commentinput.placeholder": "发表评论", "showpost.copylink.success": "链接已复制到剪贴板", - "showpost.discussionpanel.emptymessage": "还没有人发表评论.", - "showpost.label.author": "发表者 <0/> · <1/>", "showpost.message.nodescription": "未提供描述.", + "showpost.moderation.admin.description": "", + "showpost.moderation.admin.title": "", + "showpost.moderation.approved": "", + "showpost.moderation.approveerror": "", + "showpost.moderation.awaiting": "", + "showpost.moderation.comment.admin.description": "", + "showpost.moderation.comment.approved": "", + "showpost.moderation.comment.approveerror": "", + "showpost.moderation.comment.awaiting": "", + "showpost.moderation.comment.declined": "", + "showpost.moderation.comment.declineerror": "", + "showpost.moderation.commentsuccess": "", + "showpost.moderation.declined": "", + "showpost.moderation.declineerror": "", + "showpost.moderation.postsuccess": "", "showpost.moderationpanel.text.help": "此操作<0>无法撤消.", "showpost.moderationpanel.text.placeholder": "你为什么要删除这篇内容?(可选)", - "showpost.mostwanted.comments": "{count, plural, one {# comment} other {# comments}}", - "showpost.mostwanted.votes": "{count, plural, one {# votes} other {# votes}}", "showpost.notificationspanel.message.subscribed": "您正在收到有关此帖子活动的通知.", "showpost.notificationspanel.message.unsubscribed": "您将不会收到有关此帖子的任何通知.", "showpost.postsearch.numofvotes": "{0} 投票", "showpost.postsearch.query.placeholder": "搜索原始帖子...", - "showpost.response.date": "状态于 {statusDate} 更改为 {status}", "showpost.responseform.message.mergedvotes": "此帖子的投票将合并到原始帖子中.", "showpost.responseform.text.placeholder": "这篇文章怎么了?让你的用户知道你的计划是什么...", "showpost.votespanel.more": "+{extraVotesCount} 更多", @@ -208,7 +216,6 @@ "signin.code.instruction": "请输入我们刚刚发送到 <0>{email} 的验证码", "signin.code.placeholder": "在此处输入代码", "signin.code.sent": "新的验证码已发送到您的邮箱。", - "signin.code.submit": "提交", "signin.email.placeholder": "电子邮件", "signin.message.email": "通过电子邮件继续", "signin.message.emaildisabled": "管理员已禁用电子邮件身份验证。如果您有管理员帐户并且需要绕过此限制,请 <0>点击这里.", @@ -221,8 +228,5 @@ "signin.message.socialbutton.intro": "使用以下方式登录", "signin.name.placeholder": "你的名字", "validation.custom.maxattachments": "最多允许 {number} 个附件。", - "validation.custom.maximagesize": "图像大小必须小于{kilobytes}KB。", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "signin.code.expired": "此优惠码已过期或已被使用,请申请新的优惠码。", - "signin.code.invalid": "您输入的验证码无效,请重试。" + "validation.custom.maximagesize": "图像大小必须小于{kilobytes}KB。" } \ No newline at end of file diff --git a/main.go b/main.go index 63e000006..e76ca8dd0 100644 --- a/main.go +++ b/main.go @@ -4,6 +4,8 @@ import ( "os" "github.com/getfider/fider/app/cmd" + _ "github.com/getfider/fider/commercial" + _ "github.com/getfider/fider/commercial/services/sqlstore/postgres" // Import AFTER cmd to override open source handlers _ "github.com/lib/pq" ) diff --git a/migrations/202511041300_content_moderation.sql b/migrations/202511041300_content_moderation.sql new file mode 100644 index 000000000..795cb9179 --- /dev/null +++ b/migrations/202511041300_content_moderation.sql @@ -0,0 +1,57 @@ +-- Add is_moderation_enabled column to tenants table +ALTER TABLE tenants ADD COLUMN IF NOT EXISTS is_moderation_enabled BOOLEAN NULL; + +UPDATE tenants SET is_moderation_enabled = false WHERE is_moderation_enabled IS NULL; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'tenants' + AND column_name = 'is_moderation_enabled' + AND is_nullable = 'YES' + ) THEN + ALTER TABLE tenants ALTER COLUMN is_moderation_enabled SET NOT NULL; + END IF; +END $$; + +-- Add is_approved column to posts table +ALTER TABLE posts ADD COLUMN IF NOT EXISTS is_approved BOOLEAN NULL; + +UPDATE posts SET is_approved = true WHERE is_approved IS NULL; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'posts' + AND column_name = 'is_approved' + AND is_nullable = 'YES' + ) THEN + ALTER TABLE posts ALTER COLUMN is_approved SET NOT NULL; + END IF; +END $$; + +-- Add is_approved column to comments table +ALTER TABLE comments ADD COLUMN IF NOT EXISTS is_approved BOOLEAN NULL; + +UPDATE comments SET is_approved = true WHERE is_approved IS NULL; + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'comments' + AND column_name = 'is_approved' + AND is_nullable = 'YES' + ) THEN + ALTER TABLE comments ALTER COLUMN is_approved SET NOT NULL; + END IF; +END $$; + +-- Add user trust column to users table +-- This allows users to be trusted for automatic approval of their content +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_trusted boolean NOT NULL DEFAULT false; + +-- Add index for performance +CREATE INDEX IF NOT EXISTS idx_users_trust ON users(tenant_id, is_trusted) WHERE is_trusted = true; \ No newline at end of file diff --git a/migrations/202511211400_add_stripe_fields.sql b/migrations/202511211400_add_stripe_fields.sql new file mode 100644 index 000000000..8eae7b5b7 --- /dev/null +++ b/migrations/202511211400_add_stripe_fields.sql @@ -0,0 +1,2 @@ +ALTER TABLE tenants_billing ADD COLUMN stripe_customer_id VARCHAR(255) NULL; +ALTER TABLE tenants_billing ADD COLUMN stripe_subscription_id VARCHAR(255) NULL; diff --git a/migrations/202511251500_remove_paddle_trial_columns.sql b/migrations/202511251500_remove_paddle_trial_columns.sql new file mode 100644 index 000000000..52513a12f --- /dev/null +++ b/migrations/202511251500_remove_paddle_trial_columns.sql @@ -0,0 +1,11 @@ +-- Remove Paddle and trial-related columns from tenants_billing +-- Keep paddle_subscription_id to identify customers who need to migrate to Stripe +-- Make paddle_subscription_id nullable (was NOT NULL from 202109072130 migration) + +ALTER TABLE tenants_billing DROP COLUMN IF EXISTS paddle_plan_id; +ALTER TABLE tenants_billing DROP COLUMN IF EXISTS trial_ends_at; + +-- Make paddle_subscription_id nullable so new Stripe subscriptions don't fail +ALTER TABLE tenants_billing ALTER COLUMN paddle_subscription_id DROP NOT NULL; + +-- NOTE: paddle_subscription_id is intentionally kept for migration tracking diff --git a/migrations/202511251600_migrate_to_plan_system.sql b/migrations/202511251600_migrate_to_plan_system.sql new file mode 100644 index 000000000..89b2fe50c --- /dev/null +++ b/migrations/202511251600_migrate_to_plan_system.sql @@ -0,0 +1,16 @@ +-- Migrate from billing status to simple plan system +-- Active status (2) becomes Pro plan, all others become Free plan + +-- Add is_pro column to tenants table +ALTER TABLE tenants ADD COLUMN is_pro BOOLEAN NOT NULL DEFAULT false; + +-- Migrate existing data: Active billing status (2) -> Pro plan +UPDATE tenants t +SET is_pro = true +FROM tenants_billing tb +WHERE t.id = tb.tenant_id +AND tb.status = 2; + +-- Drop unused columns from tenants_billing +ALTER TABLE tenants_billing DROP COLUMN IF EXISTS status; +ALTER TABLE tenants_billing DROP COLUMN IF EXISTS subscription_ends_at; diff --git a/migrations/202511282000_add_license_key_to_billing.sql b/migrations/202511282000_add_license_key_to_billing.sql new file mode 100644 index 000000000..b51e64ff0 --- /dev/null +++ b/migrations/202511282000_add_license_key_to_billing.sql @@ -0,0 +1,2 @@ +-- Add license key storage to tenants_billing table +ALTER TABLE tenants_billing ADD COLUMN license_key TEXT; diff --git a/package.json b/package.json index 0b1b90b2f..52133631b 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "moduleNameMapper": { ".*\\.(png|scss|svg)$": "/public/jest.assets.ts", "@fider/(.*)": "/public/$1", + "@commercial/(.*)": "/commercial/$1", "@locale/(.*)": "/locale/$1" } }, diff --git a/public/assets/styles/utility/display.scss b/public/assets/styles/utility/display.scss index dd6c23b30..81c0828bf 100644 --- a/public/assets/styles/utility/display.scss +++ b/public/assets/styles/utility/display.scss @@ -200,6 +200,16 @@ border-radius: sizing(2); } +.rounded-md-t { + border-top-left-radius: sizing(2); + border-top-right-radius: sizing(2); +} + +.rounded-md-b { + border-bottom-left-radius: sizing(2); + border-bottom-right-radius: sizing(2); +} + .rounded-full { border-radius: 999px; } @@ -208,11 +218,15 @@ visibility: hidden; } -.bt { +.border-t { border-top: 1px solid; } -.bl { +.border-b { + border-bottom: 1px solid; +} + +.border-l { border-inline-start: 1px solid; } @@ -224,6 +238,10 @@ overflow: auto; } +.overflow-hidden { + overflow: hidden; +} + .border { border: 1px solid transparent; } diff --git a/public/assets/styles/utility/text.scss b/public/assets/styles/utility/text.scss index fbd6dea01..c57fdbda2 100644 --- a/public/assets/styles/utility/text.scss +++ b/public/assets/styles/utility/text.scss @@ -118,6 +118,10 @@ pre:last-child { text-align: left; } +.text-normal { + font-weight: get("font.weight.normal"); +} + .text-medium { font-weight: get("font.weight.medium"); } diff --git a/public/components/Header.tsx b/public/components/Header.tsx index e841b5a04..261154198 100644 --- a/public/components/Header.tsx +++ b/public/components/Header.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react" -import { SignInModal, RSSModal, TenantLogo, NotificationIndicator, UserMenu, ThemeSwitcher, Icon } from "@fider/components" +import { SignInModal, RSSModal, TenantLogo, NotificationIndicator, ModerationIndicator, UserMenu, ThemeSwitcher, Icon } from "@fider/components" import { useFider } from "@fider/hooks" import { HStack } from "./layout" import { Trans } from "@lingui/react/macro" @@ -41,16 +41,19 @@ export const Header = (props: HeaderProps) => {

{fider.session.tenant.name}

{fider.session.isAuthenticated && ( - - {fider.session.tenant.isFeedEnabled && ( - - )} - - - - + <> + + + {fider.session.tenant.isFeedEnabled && ( + + )} + + + + + )} {!fider.session.isAuthenticated && ( diff --git a/public/components/NotificationIndicator.tsx b/public/components/NotificationIndicator.tsx index c54776766..1b4470219 100644 --- a/public/components/NotificationIndicator.tsx +++ b/public/components/NotificationIndicator.tsx @@ -118,7 +118,7 @@ export const NotificationIndicator = () => { )} {recent !== undefined && recent?.length > 0 && ( <> -

+

Previous notifications

diff --git a/public/components/VoteCounter.spec.tsx b/public/components/VoteCounter.spec.tsx index bcbf6acb5..5d61559c9 100644 --- a/public/components/VoteCounter.spec.tsx +++ b/public/components/VoteCounter.spec.tsx @@ -4,7 +4,7 @@ import { VoteCounter } from "@fider/components" import { screen, fireEvent, render } from "@testing-library/react" import { fiderMock, httpMock, setupModalRoot } from "@fider/services/testing" import { FiderContext } from "@fider/services" -import { act } from "react-dom/test-utils" +import { act } from "react" let post: Post @@ -22,6 +22,7 @@ beforeEach(() => { user: { id: 5, name: "John", + isTrusted: false, role: UserRole.Collaborator, status: UserStatus.Active, avatarURL: "/static/avatars/letter/5/John", @@ -31,6 +32,7 @@ beforeEach(() => { votesCount: 5, commentsCount: 2, tags: [], + isApproved: true, } }) diff --git a/public/components/common/Legal.tsx b/public/components/common/Legal.tsx index 04866faeb..dee4c6fb3 100644 --- a/public/components/common/Legal.tsx +++ b/public/components/common/Legal.tsx @@ -40,8 +40,8 @@ export const LegalNotice = () => { return (

- By signing in, you agree to the - and
. + By signing in, you agree to the
+ and .

) diff --git a/public/components/common/Pagination.tsx b/public/components/common/Pagination.tsx new file mode 100644 index 000000000..1a38d8827 --- /dev/null +++ b/public/components/common/Pagination.tsx @@ -0,0 +1,103 @@ +import { Trans } from "@lingui/react/macro" +import React from "react" +import { HStack } from "../layout" +import { Button } from "./Button" + +interface PaginationProps { + currentPage: number + totalPages: number + onPageChange: (page: number) => void +} + +export const Pagination = ({ currentPage, totalPages, onPageChange }: PaginationProps) => { + if (totalPages <= 1) { + return null + } + + const getVisiblePages = () => { + const visiblePages: (number | string)[] = [] + + if (totalPages <= 7) { + // Show all pages if 7 or fewer + for (let i = 1; i <= totalPages; i++) { + visiblePages.push(i) + } + } else { + // Always show first page + visiblePages.push(1) + + if (currentPage <= 4) { + // Show first 5 pages + ellipsis + last page + for (let i = 2; i <= 5; i++) { + visiblePages.push(i) + } + visiblePages.push("...") + visiblePages.push(totalPages) + } else if (currentPage >= totalPages - 3) { + // Show first page + ellipsis + last 5 pages + visiblePages.push("...") + for (let i = totalPages - 4; i <= totalPages; i++) { + visiblePages.push(i) + } + } else { + // Show first page + ellipsis + current-1, current, current+1 + ellipsis + last page + visiblePages.push("...") + for (let i = currentPage - 1; i <= currentPage + 1; i++) { + visiblePages.push(i) + } + visiblePages.push("...") + visiblePages.push(totalPages) + } + } + + return visiblePages + } + + const handlePageClick = (page: number | string) => { + if (typeof page === "number" && page !== currentPage) { + onPageChange(page) + } + } + + const handlePrevClick = () => { + if (currentPage > 1) { + onPageChange(currentPage - 1) + } + } + + const handleNextClick = () => { + if (currentPage < totalPages) { + onPageChange(currentPage + 1) + } + } + + const visiblePages = getVisiblePages() + + return ( + + + + {/* Page Numbers */} +
+ {visiblePages.map((page, index) => ( + + {page === "..." ? ( + ... + ) : ( + + )} + + ))} +
+ + {/* Next Button */} + +
+ ) +} diff --git a/public/components/common/index.tsx b/public/components/common/index.tsx index 8426005a3..4bea87a2d 100644 --- a/public/components/common/index.tsx +++ b/public/components/common/index.tsx @@ -30,6 +30,7 @@ export * from "./PoweredByFider" export * from "./PageTitle" export * from "./Dropdown" export * from "./Money" +export * from "./Pagination" import Textarea from "react-textarea-autosize" export { Textarea } diff --git a/public/components/index.tsx b/public/components/index.tsx index 55a123ae7..74fb08b10 100644 --- a/public/components/index.tsx +++ b/public/components/index.tsx @@ -8,6 +8,7 @@ export * from "./SignInModal" export * from "./RSSModal" export * from "./VoteCounter" export * from "./NotificationIndicator" +export * from "@commercial/components/ModerationIndicator" export * from "./UserMenu" export * from "./Reactions" export * from "./ReadOnlyNotice" diff --git a/public/models/billing.ts b/public/models/billing.ts deleted file mode 100644 index f79bd3136..000000000 --- a/public/models/billing.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum BillingStatus { - Trial = 1, - Active = 2, - Cancelled = 3, - FreeForever = 4, - OpenCollective = 5, -} diff --git a/public/models/identity.ts b/public/models/identity.ts index 4e724f9e2..207e60f97 100644 --- a/public/models/identity.ts +++ b/public/models/identity.ts @@ -12,6 +12,8 @@ export interface Tenant { allowedSchemes: string isEmailAuthAllowed: boolean isFeedEnabled: boolean + isModerationEnabled: boolean + isCommercial: boolean } export enum TenantStatus { @@ -27,6 +29,7 @@ export interface User { email?: string role: UserRole status: UserStatus + isTrusted: boolean avatarURL: string } @@ -57,6 +60,10 @@ export const isCollaborator = (role: UserRole): boolean => { return role === UserRole.Collaborator || role === UserRole.Administrator } +export const requiresModeration = (user: User): boolean => { + return user.role === UserRole.Visitor && !user.isTrusted +} + export interface CurrentUser { id: number name: string @@ -68,4 +75,5 @@ export interface CurrentUser { status: UserStatus isAdministrator: boolean isCollaborator: boolean + isTrusted: boolean } diff --git a/public/models/index.ts b/public/models/index.ts index 6fd4e25bb..50493048b 100644 --- a/public/models/index.ts +++ b/public/models/index.ts @@ -1,6 +1,5 @@ export * from "./post" export * from "./identity" export * from "./settings" -export * from "./billing" export * from "./notification" export * from "./webhook" diff --git a/public/models/post.ts b/public/models/post.ts index e9f5a6ff2..2c5fd6bac 100644 --- a/public/models/post.ts +++ b/public/models/post.ts @@ -14,6 +14,7 @@ export interface Post { votesCount: number commentsCount: number tags: string[] + isApproved: boolean } export class PostStatus { @@ -66,6 +67,7 @@ export interface Comment { reactionCounts?: ReactionCount[] editedAt?: string editedBy?: User + isApproved: boolean } export interface Tag { diff --git a/public/pages/Administration/components/SideMenu.tsx b/public/pages/Administration/components/SideMenu.tsx index b88e7b321..acde6b97c 100644 --- a/public/pages/Administration/components/SideMenu.tsx +++ b/public/pages/Administration/components/SideMenu.tsx @@ -42,7 +42,7 @@ export const SideMenu = (props: SiteMenuProps) => { - + diff --git a/public/pages/Administration/hooks/use-paddle.ts b/public/pages/Administration/hooks/use-paddle.ts deleted file mode 100644 index 8c92d6f44..000000000 --- a/public/pages/Administration/hooks/use-paddle.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useCache, useScript } from "@fider/hooks" -import { actions } from "@fider/services" -import { useEffect, useState } from "react" - -interface UsePaddleParams { - isSandbox: boolean - vendorId: string - monthlyPlanId: string - yearlyPlanId: string -} - -export function usePaddle(params: UsePaddleParams) { - const status = useScript("https://cdn.paddle.com/paddle/paddle.js") - const [monthlyPrice, setMonthlyPrice] = useCache("monthlyPrice", "$30") - const [yearlyPrice, setYearlyPrice] = useCache("yearlyPrice", "$300") - const [isReady, setIsReady] = useState(false) - - useEffect(() => { - if (status !== "ready" || !params) return - - if (params.isSandbox) { - window.Paddle.Environment.set("sandbox") - } - - const vendor = parseInt(params.vendorId, 10) - window.Paddle.Setup({ vendor }) - setIsReady(true) - - window.Paddle.Product.Prices(parseInt(params.monthlyPlanId, 10), (resp) => { - setMonthlyPrice(resp.price.net.replace(/\.00/g, "")) - }) - window.Paddle.Product.Prices(parseInt(params.yearlyPlanId, 10), (resp) => { - setYearlyPrice(resp.price.net.replace(/\.00/g, "")) - }) - }, [status]) - - const openUrl = (url: string) => { - window.Paddle.Checkout.open({ - override: url, - closeCallback: () => { - location.reload() - }, - }) - } - - const subscribeMonthly = async () => { - const result = await actions.generateCheckoutLink(params.monthlyPlanId) - if (result.ok) { - openUrl(result.data.url) - } - } - - const subscribeYearly = async () => { - const result = await actions.generateCheckoutLink(params.yearlyPlanId) - if (result.ok) { - openUrl(result.data.url) - } - } - - return { isReady, monthlyPrice, yearlyPrice, openUrl, subscribeMonthly, subscribeYearly } -} diff --git a/public/pages/Administration/pages/AdvancedSettings.page.tsx b/public/pages/Administration/pages/AdvancedSettings.page.tsx index c7c893ec6..f41b0cfa4 100644 --- a/public/pages/Administration/pages/AdvancedSettings.page.tsx +++ b/public/pages/Administration/pages/AdvancedSettings.page.tsx @@ -7,12 +7,15 @@ import { AdminBasePage } from "../components/AdminBasePage" interface AdvancedSettingsPageProps { customCSS: string allowedSchemes: string + licenseKey: string + isCommercial: boolean } interface AdvancedSettingsPageState { customCSS: string allowedSchemes: string error?: Failure + copied: boolean } export default class AdvancedSettingsPage extends AdminBasePage { @@ -27,6 +30,7 @@ export default class AdvancedSettingsPage extends AdminBasePage { + navigator.clipboard.writeText(this.props.licenseKey) + this.setState({ copied: true }) + setTimeout(() => this.setState({ copied: false }), 2000) + } + public content() { return (
+ {this.props.isCommercial && this.props.licenseKey && ( +
+ +

+ Use this key to run Fider self-hosted with commercial features (content moderation). +

+
+
+ + +
+
+
+ )} +