Compare commits

..

9 Commits

Author SHA1 Message Date
Jonathan Miller 0cc7297f23 fix: remove unused net/http import in require2fa.go
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:39:15 -05:00
jmiller 9dc85cfc2d Merge pull request 'feat: smart wiki filenames' (#215) from fix/wiki-smart-filenames into dev 2026-05-26 18:28:14 +00:00
jmiller 6bc0cb5bc8 Merge pull request 'feat: org-level 2FA requirement (#208)' (#214) from feat/208-org-2fa-requirement into dev 2026-05-26 18:28:05 +00:00
Jonathan Miller 1fb97eeeeb feat: smart wiki filenames — sanitize special characters to hyphens
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 3s
New wiki page titles are now sanitized before creating the git file:
- Spaces and special characters replaced with hyphens
- Consecutive hyphens collapsed to single hyphen
- Leading/trailing hyphens trimmed

Examples:
- "My Page Name" -> "My-Page-Name"
- "API & Docs (v2)" -> "API-Docs-v2"
- "100% Complete!!" -> "100-Complete"

Only affects NEW pages. Existing wiki pages with legacy filenames
(spaces, URL encoding) continue to work — the read path is unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:22:21 -05:00
Jonathan Miller 1032ae4268 feat: organization-level 2FA requirement for members (#208)
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Adds a Require2FA toggle to organization settings. When enabled,
org members without 2FA are redirected to the security settings
page with a warning flash message.

Changes:
- New Require2FA field on User model (migration v333)
- Org settings UI checkbox with shield-lock icon
- Check2FARequirement middleware on member-required org routes
- UpdateOptions extended with Require2FA field

Closes #208

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 13:11:15 -05:00
jmiller af1c6178ef Merge pull request 'fix: http content file render (#207)' (#212) from fix/207-http-content-render into dev 2026-05-26 17:55:43 +00:00
Giteabot 0f23219ee4 fix: http content file render (#37850) (#37856)
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Backport #37850

Fix #37849

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: TheFox0x7 <thefox0x7@gmail.com>
2026-05-26 12:54:37 -05:00
jmiller bc475c91f6 Merge pull request 'feat: login notification via email and ntfy' (#209) from feat/login-notifications into dev
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 21s
2026-05-26 16:40:51 +00:00
Jonathan Miller 25268d7dd7 feat: login notification via email and ntfy on successful sign-in
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
When a user signs in, sends notifications with username, IP address,
user agent, and timestamp. Notifications go through:
- Email to the user's registered address
- ntfy push to the configured topic

Enabled by default, configurable via app.ini:
  [login_notification]
  ENABLED = true

The notification fires asynchronously (goroutine) so it doesn't
block the login redirect. Hooks into handleSignInFull which is the
single choke point for all auth methods (password, 2FA, OAuth).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 11:39:44 -05:00
14 changed files with 210 additions and 8 deletions
+1
View File
@@ -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
}
+16
View File
@@ -0,0 +1,16 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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
}
+3
View File
@@ -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"`
+14 -2
View File
@@ -63,7 +63,7 @@ func TestFile(t *testing.T) {
{
name: "tags.py",
code: "<>",
want: lines(`<span class="o">&lt;</span><span class="o">&gt;</span>`),
want: lines(`<span class="o">&lt;&gt;</span>`),
lexerName: "Python",
},
{
@@ -102,7 +102,7 @@ c=2
<span class="n">def</span><span class="p">:</span>\n
<span class="n">a</span><span class="o">=</span><span class="mi">1</span>\n
\n
<span class="n">b</span><span class="o">=</span><span class="sa"></span><span class="s1">&#39;</span><span class="s1">&#39;</span>\n
<span class="n">b</span><span class="o">=</span><span class="s1">&#39;&#39;</span>\n
\n
<span class="n">c</span><span class="o">=</span><span class="mi">2</span>`,
),
@@ -114,6 +114,18 @@ c=2
want: []template.HTML{"<span class=\"c1\">--\n</span>", `<span class="k">SELECT</span>`},
lexerName: "SQL",
},
{
name: "test.http",
code: `HTTP/1.0 400 Bad request
Content-Type: text/html
<html></html>`,
want: lines(`<span class="kr">HTTP</span><span class="o">/</span><span class="m">1.0</span> <span class="m">400</span> <span class="ne">Bad request</span>\n
<span class="n">Content-Type</span><span class="o">:</span> <span class="l">text/html</span>\n
\n
<span class="p">&lt;</span><span class="nt">html</span><span class="p">&gt;&lt;/</span><span class="nt">html</span><span class="p">&gt;</span>`),
lexerName: "HTTP",
},
}
for _, tt := range tests {
+4 -4
View File
@@ -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)
}
+13
View File
@@ -39,6 +39,13 @@ var (
Channel: "stable",
}
// LoginNotification configuration for sign-in alerts
LoginNotification = struct {
Enabled bool
}{
Enabled: true,
}
// IsInTesting indicates whether the testing is running (unit test or integration test). It can be used for:
// * Skip nonsense error logs during testing caused by unreliable code (TODO: this is only a temporary solution, we should make the test code more reliable)
// * Panic in dev or testing mode to make the problem more obvious and easier to debug
@@ -171,6 +178,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
loadOtherFrom(cfg)
loadUpdateCheckerFrom(cfg)
loadNtfyFrom(cfg)
loadLoginNotificationFrom(cfg)
return nil
}
@@ -181,6 +189,11 @@ func loadUpdateCheckerFrom(cfg ConfigProvider) {
UpdateChecker.Channel = sec.Key("CHANNEL").MustString(UpdateChecker.Channel)
}
func loadLoginNotificationFrom(cfg ConfigProvider) {
sec := cfg.Section("login_notification")
LoginNotification.Enabled = sec.Key("ENABLED").MustBool(true)
}
func loadRunModeFrom(rootCfg ConfigProvider) {
rootSec := rootCfg.Section("")
RunUser = rootSec.Key("RUN_USER").MustString(user.CurrentUsername())
+3
View File
@@ -440,6 +440,9 @@ func handleSignInFull(ctx *context.Context, u *user_model.User, remember bool) {
ctx.ServerError("UpdateUser", err)
return
}
// Send login notification (email + ntfy)
go mailer.SendLoginNotification(u, ctx.RemoteAddr(), ctx.Req.UserAgent())
}
// extractUserNameFromOAuth2 tries to extract a normalized username from the given OAuth2 user.
+37
View File
@@ -0,0 +1,37 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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")
}
+2
View File
@@ -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)
+1 -1
View File
@@ -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)
+84
View File
@@ -0,0 +1,84 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package mailer
import (
"bytes"
"fmt"
"io"
"net/http"
"time"
user_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
sender_service "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/mailer/sender"
)
// SendLoginNotification sends email and ntfy notifications when a user signs in.
func SendLoginNotification(u *user_model.User, ip, userAgent string) {
if !setting.LoginNotification.Enabled {
return
}
timestamp := time.Now().UTC().Format("2006-01-02 15:04:05 UTC")
subject := fmt.Sprintf("[%s] New sign-in: %s", setting.AppName, u.Name)
body := fmt.Sprintf(`New sign-in detected
Account: %s (%s)
IP Address: %s
Browser: %s
Time: %s
Instance: %s
If this wasn't you, change your password immediately and review your active sessions.
— %s`, u.Name, u.Email, ip, userAgent, timestamp, setting.AppURL, setting.AppName)
// Email notification
if setting.MailService != nil && u.Email != "" {
msg := sender_service.NewMessage(u.EmailTo(), subject, body)
msg.Info = fmt.Sprintf("Login notification for %s", u.Name)
SendAsync(msg)
log.Debug("Login notification email sent to %s", u.Email)
}
// ntfy push notification
if setting.Ntfy.Enabled && setting.Ntfy.ServerURL != "" {
go sendLoginNtfy(subject, u.Name, ip, timestamp)
}
}
func sendLoginNtfy(title, username, ip, timestamp string) {
body := fmt.Sprintf("User: %s\nIP: %s\nTime: %s", username, ip, timestamp)
url := fmt.Sprintf("%s/%s", setting.Ntfy.ServerURL, setting.Ntfy.DefaultTopic)
req, err := http.NewRequest("POST", url, bytes.NewBufferString(body))
if err != nil {
log.Error("ntfy login: create request: %v", err)
return
}
req.Header.Set("Title", title)
req.Header.Set("Priority", "default")
req.Header.Set("Tags", "key,login")
req.Header.Set("Click", setting.AppURL+"-/admin")
if setting.Ntfy.Token != "" {
req.Header.Set("Authorization", "Bearer "+setting.Ntfy.Token)
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
log.Error("ntfy login: send: %v", err)
return
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 300 {
log.Error("ntfy login: status %d", resp.StatusCode)
}
}
+6
View File
@@ -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()
+16 -1
View File
@@ -6,6 +6,7 @@ package wiki
import (
"net/url"
"path"
"regexp"
"strings"
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
@@ -148,10 +149,24 @@ func WebPathFromRequest(s string) WebPath {
return WebPath(s)
}
var multiHyphenRe = regexp.MustCompile(`-{2,}`)
var nonAlphanumRe = 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.
func sanitizeWikiTitle(title string) string {
title = strings.TrimSpace(title)
title = strings.ReplaceAll(title, " ", "-")
title = nonAlphanumRe.ReplaceAllString(title, "-")
title = multiHyphenRe.ReplaceAllString(title, "-")
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"
+10
View File
@@ -48,6 +48,16 @@
</div>
{{end}}
<div class="divider"></div>
<div class="inline field">
<div class="ui checkbox">
<input type="checkbox" name="require_2fa" {{if .Org.Require2FA}}checked{{end}}>
<label>{{svg "octicon-shield-lock" 16}} Require two-factor authentication for all members</label>
</div>
<p class="help">When enabled, organization members without 2FA configured will be prompted to set it up before accessing organization resources.</p>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "org.settings.update_settings"}}</button>
</div>