diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 627bc04dfa..e362332ca5 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -410,6 +410,7 @@ func prepareMigrationTasks() []*migration { newMigration(331, "Add ActionRunAttempt model and related action fields", v1_27.AddActionRunAttemptModel), newMigration(332, "Add org-level branch protection rulesets", v1_27.AddOrgProtectedBranchTable), + newMigration(333, "Add require_2fa to user table for org enforcement", v1_27.AddRequire2FAToUser), } return preparedMigrations } diff --git a/models/migrations/v1_27/v333.go b/models/migrations/v1_27/v333.go new file mode 100644 index 0000000000..e245b6d73d --- /dev/null +++ b/models/migrations/v1_27/v333.go @@ -0,0 +1,16 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import ( + "xorm.io/xorm" +) + +func AddRequire2FAToUser(x *xorm.Engine) error { + type User struct { + Require2FA bool `xorm:"NOT NULL DEFAULT false"` + } + _, err := x.SyncWithOptions(xorm.SyncOptions{IgnoreDropIndices: true}, new(User)) + return err +} diff --git a/models/user/user.go b/models/user/user.go index 2f259a3416..f7dcc407e8 100644 --- a/models/user/user.go +++ b/models/user/user.go @@ -117,6 +117,9 @@ type User struct { // Maximum repository creation limit, -1 means use global default MaxRepoCreation int `xorm:"NOT NULL DEFAULT -1"` + // Require2FA when true (and user is an org), all org members must have 2FA enabled + Require2FA bool `xorm:"NOT NULL DEFAULT false"` + // IsActive true: primary email is activated, user can access Web UI and Git SSH. // false: an inactive user can only log in Web UI for account operations (ex: activate the account by email), no other access. IsActive bool `xorm:"INDEX"` diff --git a/modules/highlight/highlight_test.go b/modules/highlight/highlight_test.go index 211132b255..a31f8752f1 100644 --- a/modules/highlight/highlight_test.go +++ b/modules/highlight/highlight_test.go @@ -63,7 +63,7 @@ func TestFile(t *testing.T) { { name: "tags.py", code: "<>", - want: lines(`<>`), + want: lines(`<>`), lexerName: "Python", }, { @@ -102,7 +102,7 @@ c=2 def:\n a=1\n \n -b=''\n +b=''\n \n c=2`, ), @@ -114,6 +114,18 @@ c=2 want: []template.HTML{"--\n", `SELECT`}, lexerName: "SQL", }, + { + name: "test.http", + code: `HTTP/1.0 400 Bad request +Content-Type: text/html + +`, + want: lines(`HTTP/1.0 400 Bad request\n +Content-Type: text/html\n +\n +<html></html>`), + lexerName: "HTTP", + }, } for _, tt := range tests { diff --git a/modules/highlight/lexerdetect.go b/modules/highlight/lexerdetect.go index b31080e8a6..ff6ec1a9a4 100644 --- a/modules/highlight/lexerdetect.go +++ b/modules/highlight/lexerdetect.go @@ -288,24 +288,24 @@ func detectChromaLexerWithAnalyze(fileName, lang string, code []byte) chroma.Lex // if lang is provided, and it matches a lexer, use it directly if byLang { - return lexer + return chroma.Coalesce(lexer) } // if a lexer is detected and there is no conflict for the file extension, use it directly fileExt := path.Ext(fileName) _, hasConflicts := chromaLexers().conflictingExtLangMap[fileExt] if !hasConflicts && lexer != lexers.Fallback { - return lexer + return chroma.Coalesce(lexer) } // try to detect language by content, for best guessing for the language // when using "code" to detect, analyze.GetCodeLanguage is slow, it iterates many rules to detect language from content analyzedLanguage := analyze.GetCodeLanguage(fileName, code) - lexer = DetectChromaLexerByFileName(fileName, analyzedLanguage) + lexer, _ = detectChromaLexerByFileName(fileName, analyzedLanguage) if lexer == lexers.Fallback { if analyzedLanguage != enry.OtherLanguage { log.Warn("No chroma lexer found for enry detected language: %s (file: %s), need to fix the language mapping between enry and chroma.", analyzedLanguage, fileName) } } - return lexer + return chroma.Coalesce(lexer) } diff --git a/routers/web/org/require2fa.go b/routers/web/org/require2fa.go new file mode 100644 index 0000000000..4fda89425b --- /dev/null +++ b/routers/web/org/require2fa.go @@ -0,0 +1,37 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package org + +import ( + auth_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/auth" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" +) + +// Check2FARequirement checks if the current org requires 2FA and if the user has it enabled. +// If the user doesn't have 2FA and the org requires it, redirect to 2FA setup page. +func Check2FARequirement(ctx *context.Context) { + if ctx.Org == nil || ctx.Org.Organization == nil || ctx.Doer == nil { + return + } + + if !ctx.Org.Organization.Require2FA { + return + } + + // Check if user has 2FA enabled + has, err := auth_model.HasTwoFactorOrWebAuthn(ctx, ctx.Doer.ID) + if err != nil { + ctx.ServerError("HasTwoFactorOrWebAuthn", err) + return + } + + if has { + return + } + + // User doesn't have 2FA — show warning and redirect to settings + ctx.Flash.Warning("This organization requires two-factor authentication. Please enable 2FA to continue.") + ctx.Redirect(setting.AppSubURL + "/user/settings/security") +} diff --git a/routers/web/org/setting.go b/routers/web/org/setting.go index e9153cd6c2..9ba6c03840 100644 --- a/routers/web/org/setting.go +++ b/routers/web/org/setting.go @@ -80,12 +80,14 @@ func SettingsPost(ctx *context.Context) { return } + require2FA := ctx.FormBool("require_2fa") opts := &user_service.UpdateOptions{ FullName: optional.FromPtr(form.FullName), Description: optional.FromPtr(form.Description), Website: optional.FromPtr(form.Website), Location: optional.FromPtr(form.Location), RepoAdminChangeTeamAccess: optional.FromPtr(form.RepoAdminChangeTeamAccess), + Require2FA: optional.Some(require2FA), } if ctx.Doer.IsAdmin { opts.MaxRepoCreation = optional.FromPtr(form.MaxRepoCreation) diff --git a/routers/web/web.go b/routers/web/web.go index 3a831b02c1..452ad77302 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -960,7 +960,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { m.Get("/milestones/{team}", reqMilestonesDashboardPageEnabled, user.Milestones) m.Post("/members/action/{action}", org.MembersAction) m.Get("/teams", org.Teams) - }, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true})) + }, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true}), org.Check2FARequirement) m.Group("/{org}", func() { m.Get("/teams/{team}", org.TeamMembers) diff --git a/services/user/update.go b/services/user/update.go index 988e6dd2c8..64356a7b9a 100644 --- a/services/user/update.go +++ b/services/user/update.go @@ -56,6 +56,7 @@ type UpdateOptions struct { EmailNotificationsPreference optional.Option[string] SetLastLogin bool RepoAdminChangeTeamAccess optional.Option[bool] + Require2FA optional.Option[bool] } func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) error { @@ -169,6 +170,11 @@ func UpdateUser(ctx context.Context, u *user_model.User, opts *UpdateOptions) er cols = append(cols, "repo_admin_change_team_access") } + if opts.Require2FA.Has() { + u.Require2FA = opts.Require2FA.Value() + + cols = append(cols, "require_2fa") + } if opts.EmailNotificationsPreference.Has() { u.EmailNotificationsPreference = opts.EmailNotificationsPreference.Value() diff --git a/services/wiki/wiki_path.go b/services/wiki/wiki_path.go index 91363322e1..9054127f44 100644 --- a/services/wiki/wiki_path.go +++ b/services/wiki/wiki_path.go @@ -6,6 +6,7 @@ package wiki import ( "net/url" "path" + "regexp" "strings" repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" @@ -148,10 +149,26 @@ func WebPathFromRequest(s string) WebPath { return WebPath(s) } +var multiHyphenRe = regexp.MustCompile(`-{2,}`) +var nonSlugRe = regexp.MustCompile(`[^a-zA-Z0-9+.\-]`) + +// sanitizeWikiTitle converts a user-provided title into a clean, URL-friendly slug. +// Spaces and special characters become hyphens, consecutive hyphens collapse to one. +// Preserves: letters, digits, hyphens, plus signs (+), and dots (.) +func sanitizeWikiTitle(title string) string { + title = strings.TrimSpace(title) + title = strings.ReplaceAll(title, " ", "-") + title = nonSlugRe.ReplaceAllString(title, "-") + title = multiHyphenRe.ReplaceAllString(title, "-") + title = strings.NewReplacer("-+-", "-", "+-", "-", "-+", "-").Replace(title) // clean stray plus signs + title = strings.Trim(title, "-+.") + return title +} + func UserTitleToWebPath(base, title string) WebPath { // TODO: no support for subdirectory, because the old wiki code's behavior is always using %2F, instead of subdirectory. // So we do not add the support for writing slashes in title at the moment. - title = strings.TrimSpace(title) + title = sanitizeWikiTitle(title) title = util.PathJoinRelX(base, escapeSegToWeb(title, false)) if title == "" || title == "." { title = "unnamed" diff --git a/templates/org/settings/options.tmpl b/templates/org/settings/options.tmpl index 614e861b09..5bd9bda4e4 100644 --- a/templates/org/settings/options.tmpl +++ b/templates/org/settings/options.tmpl @@ -48,6 +48,16 @@ {{end}} +
+ +
+
+ + +
+

When enabled, organization members without 2FA configured will be prompted to set it up before accessing organization resources.

+
+