Compare commits

..

10 Commits

17 changed files with 9 additions and 215 deletions
-1
View File
@@ -410,7 +410,6 @@ 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
@@ -1,16 +0,0 @@
// 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,9 +117,6 @@ 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"`
+2 -14
View File
@@ -63,7 +63,7 @@ func TestFile(t *testing.T) {
{
name: "tags.py",
code: "<>",
want: lines(`<span class="o">&lt;&gt;</span>`),
want: lines(`<span class="o">&lt;</span><span class="o">&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="s1">&#39;&#39;</span>\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
\n
<span class="n">c</span><span class="o">=</span><span class="mi">2</span>`,
),
@@ -114,18 +114,6 @@ 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 chroma.Coalesce(lexer)
return 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 chroma.Coalesce(lexer)
return 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 chroma.Coalesce(lexer)
return lexer
}
-13
View File
@@ -39,13 +39,6 @@ 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
@@ -178,7 +171,6 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
loadOtherFrom(cfg)
loadUpdateCheckerFrom(cfg)
loadNtfyFrom(cfg)
loadLoginNotificationFrom(cfg)
return nil
}
@@ -189,11 +181,6 @@ 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,9 +440,6 @@ 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
@@ -1,37 +0,0 @@
// 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,14 +80,12 @@ 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}), org.Check2FARequirement)
}, context.OrgAssignment(context.OrgAssignmentOptions{RequireMember: true, RequireTeamMember: true}))
m.Group("/{org}", func() {
m.Get("/teams/{team}", org.TeamMembers)
-84
View File
@@ -1,84 +0,0 @@
// 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,7 +56,6 @@ 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 {
@@ -170,11 +169,6 @@ 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()
+1 -16
View File
@@ -6,7 +6,6 @@ package wiki
import (
"net/url"
"path"
"regexp"
"strings"
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
@@ -149,24 +148,10 @@ 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 = sanitizeWikiTitle(title)
title = strings.TrimSpace(title)
title = util.PathJoinRelX(base, escapeSegToWeb(title, false))
if title == "" || title == "." {
title = "unnamed"
+1 -1
View File
@@ -6,7 +6,7 @@
<summary>{{svg "octicon-tools" 16}} {{ctx.Locale.Tr "admin.maintenance"}}</summary>
<div class="menu">
<a class="{{if .PageIsAdminDashboard}}active {{end}}item" href="{{AppSubUrl}}/-/admin">
{{svg "octicon-meter" 16}} {{ctx.Locale.Tr "admin.dashboard"}}
{{svg "octicon-dashboard" 16}} {{ctx.Locale.Tr "admin.dashboard"}}
</a>
<a class="{{if .PageIsAdminSelfCheck}}active {{end}}item" href="{{AppSubUrl}}/-/admin/self_check">
{{svg "octicon-check-circle" 16}} {{ctx.Locale.Tr "admin.self_check"}}
-1
View File
@@ -36,7 +36,6 @@
</div>
<a href="{{AssetUrlPrefix}}/licenses.txt">{{ctx.Locale.Tr "licenses"}}</a>
{{if .EnableSwagger}}<a href="{{AppSubUrl}}/api/swagger">API</a>{{end}}
<a href="{{HelpURL}}" target="_blank">{{ctx.Locale.Tr "help"}}</a>
{{template "custom/extra_links_footer" .}}
</div>
</footer>
-10
View File
@@ -48,16 +48,6 @@
</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>
-3
View File
@@ -1,7 +1,4 @@
<div class="ui container fluid">
<div class="tw-text-center tw-mb-4">
<img src="{{AssetUrlPrefix}}/img/login-logo.png" style="max-width: 220px; max-height: 80px; object-fit: contain;" onerror="this.style.display='none'">
</div>
{{if or (not .LinkAccountMode) (and .LinkAccountMode .LinkAccountModeSignIn)}}
{{template "base/alert" .}}
{{end}}