gitforge/services/context/context.go
AverageHelper 408572dd35 feat: in-browser validation of website URLs for user, repository, and organization profiles (#12991)
This PR pertains to the client-side validation of the Website input on user, repo, and org profiles. #12962 extends `[service].VALID_SITE_URL_SCHEMES` to cover Website fields on repo and org profiles, where before that config key only applied to the one on user profiles. If that change merges, it will then be possible to construct an HTML [`pattern`](https://developer.mozilla.org/docs/Web/HTML/Reference/Elements/input#pattern) attribute for general use on any Website form input that the server validates this way, thus enabling browsers to catch errors early relating to URL scheme confusion.

This PR (1) introduces such a `pattern` attribute, and (2) adds a new UI note to make clear to users which URL schemes are permitted. This change helps explain the browser's otherwise cryptic error messages regarding pattern mismatch, while also letting users know what URI schemes the Forgejo instance supports as Website links (e.g. gemini:// URLs).

![A text field labeled "Website", with a note below which reads, "Allowed URL schemes include: http, https"](/attachments/304e17ee-b5aa-414e-a4fa-d48639336c6c)

This MUST NOT merge before #12962. To do so would introduce a regression wherein the UI may suggest and validate a different set of allowed URL schemes than the server actually permits.

See also #5519

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12991
Reviewed-by: 0ko <0ko@noreply.codeberg.org>
2026-06-08 17:35:15 +02:00

264 lines
8.9 KiB
Go

// Copyright 2014 The Gogs Authors. All rights reserved.
// Copyright 2020 The Gitea Authors. All rights reserved.
// Copyright 2024 The Forgejo Authors.
// SPDX-License-Identifier: MIT
package context
import (
"context"
"fmt"
"html/template"
"io"
"net/http"
"net/url"
"strings"
"forgejo.org/models/unit"
user_model "forgejo.org/models/user"
mc "forgejo.org/modules/cache"
"forgejo.org/modules/gitrepo"
"forgejo.org/modules/httpcache"
"forgejo.org/modules/setting"
"forgejo.org/modules/templates"
"forgejo.org/modules/translation"
"forgejo.org/modules/web"
"forgejo.org/modules/web/middleware"
web_types "forgejo.org/modules/web/types"
"forgejo.org/services/auth"
"code.forgejo.org/go-chi/cache"
"code.forgejo.org/go-chi/session"
)
// Render represents a template render
type Render interface {
TemplateLookup(tmpl string, templateCtx context.Context) (templates.TemplateExecutor, error)
HTML(w io.Writer, status int, name string, data any, templateCtx context.Context) error
}
// Context represents context of a request.
type Context struct {
*Base
TemplateContext *templates.Context
Render Render
PageData map[string]any // data used by JavaScript modules in one page, it's `window.config.pageData`
Cache cache.Cache
Flash *middleware.Flash
Session session.Store
Link string // current request URL (without query string)
Doer *user_model.User // current signed-in user
IsSigned bool
Authentication auth.AuthenticationResult
ContextUser *user_model.User // the user which is being visited, in most cases it differs from Doer
Repo *Repository
Org *Organization
Package *Package
}
func init() {
web.RegisterResponseStatusProvider[*Context](func(req *http.Request) web_types.ResponseStatusProvider {
return req.Context().Value(WebContextKey).(*Context)
})
}
type webContextKeyType struct{}
var WebContextKey = webContextKeyType{}
func GetWebContext(req *http.Request) *Context {
ctx, _ := req.Context().Value(WebContextKey).(*Context)
return ctx
}
// ValidateContext is a special context for form validation middleware. It may be different from other contexts.
type ValidateContext struct {
*Base
}
// GetValidateContext gets a context for middleware form validation
func GetValidateContext(req *http.Request) (ctx *ValidateContext) {
if ctxAPI, ok := req.Context().Value(apiContextKey).(*APIContext); ok {
ctx = &ValidateContext{Base: ctxAPI.Base}
} else if ctxWeb, ok := req.Context().Value(WebContextKey).(*Context); ok {
ctx = &ValidateContext{Base: ctxWeb.Base}
} else {
panic("invalid context, expect either APIContext or Context")
}
return ctx
}
func NewTemplateContextForWeb(ctx *Context) *templates.Context {
tmplCtx := templates.NewContext(ctx)
tmplCtx.Locale = ctx.Locale
tmplCtx.AvatarUtils = templates.NewAvatarUtils(ctx)
tmplCtx.Data = ctx.Data
return tmplCtx
}
func NewWebContext(base *Base, render Render, session session.Store) *Context {
ctx := &Context{
Base: base,
Render: render,
Session: session,
Cache: mc.GetCache(),
Link: setting.AppSubURL + strings.TrimSuffix(base.Req.URL.EscapedPath(), "/"),
Repo: &Repository{PullRequest: &PullRequest{}},
Org: &Organization{},
Authentication: &auth.UnauthenticatedResult{},
}
ctx.TemplateContext = NewTemplateContextForWeb(ctx)
ctx.Flash = &middleware.Flash{DataStore: ctx, Values: url.Values{}}
return ctx
}
func (ctx *Context) AddPluralStringsToPageData(keys []string) {
for _, key := range keys {
array, fallback := ctx.Locale.TrPluralStringAllForms(key)
ctx.PageData["PLURALSTRINGS_LANG"].(map[string][]string)[key] = array
if fallback != nil {
ctx.PageData["PLURALSTRINGS_FALLBACK"].(map[string][]string)[key] = fallback
}
}
}
// Contexter initializes a classic context for a request.
func Contexter() func(next http.Handler) http.Handler {
rnd := templates.HTMLRenderer()
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
base, baseCleanUp := NewBaseContext(resp, req)
defer baseCleanUp()
ctx := NewWebContext(base, rnd, session.GetSession(req))
ctx.Data.MergeFrom(middleware.CommonTemplateContextData())
ctx.Data["Context"] = ctx // TODO: use "ctx" in template and remove this
ctx.Data["CurrentURL"] = setting.AppSubURL + req.URL.RequestURI()
ctx.Data["Link"] = ctx.Link
ctx.Data["ValidSiteURLSchemes"] = setting.Service.ValidSiteURLSchemes
ctx.Data["ValidSiteURLPattern"] = setting.ValidSiteURLPattern()
// PageData is passed by reference, and it will be rendered to `window.config.pageData` in `head.tmpl` for JavaScript modules
ctx.PageData = map[string]any{}
ctx.Data["PageData"] = ctx.PageData
ctx.AppendContextValue(WebContextKey, ctx)
ctx.AppendContextValueFunc(gitrepo.RepositoryContextKey, func() any { return ctx.Repo.GitRepo })
// Get the last flash message from cookie
lastFlashCookie := middleware.GetSiteCookie(ctx.Req, CookieNameFlash)
if vals, _ := url.ParseQuery(lastFlashCookie); len(vals) > 0 {
// store last Flash message into the template data, to render it
ctx.Data["Flash"] = &middleware.Flash{
DataStore: ctx,
Values: vals,
ErrorMsg: vals.Get("error"),
SuccessMsg: vals.Get("success"),
InfoMsg: vals.Get("info"),
WarningMsg: vals.Get("warning"),
}
}
// if there are new messages in the ctx.Flash, write them into cookie
ctx.Resp.Before(func(resp ResponseWriter) {
if val := ctx.Flash.Encode(); val != "" {
middleware.SetSiteCookie(ctx.Resp, CookieNameFlash, val, 0)
} else if lastFlashCookie != "" {
middleware.SetSiteCookie(ctx.Resp, CookieNameFlash, "", -1)
}
})
httpcache.SetCacheControlInHeader(ctx.Resp.Header(), 0)
ctx.Resp.Header().Set(`X-Frame-Options`, setting.CORSConfig.XFrameOptions)
ctx.Data["SystemConfig"] = setting.Config()
// FIXME: do we really always need these setting? There should be someway to have to avoid having to always set these
ctx.Data["DisableMigrations"] = setting.Repository.DisableMigrations
ctx.Data["DisableStars"] = setting.Repository.DisableStars
ctx.Data["DisableForks"] = setting.Repository.DisableForks
ctx.Data["EnableActions"] = setting.Actions.Enabled
ctx.Data["EnableFederation"] = setting.Federation.Enabled
ctx.Data["UnitWikiGlobalDisabled"] = unit.TypeWiki.UnitGlobalDisabled()
ctx.Data["UnitIssuesGlobalDisabled"] = unit.TypeIssues.UnitGlobalDisabled()
ctx.Data["UnitPullsGlobalDisabled"] = unit.TypePullRequests.UnitGlobalDisabled()
ctx.Data["UnitProjectsGlobalDisabled"] = unit.TypeProjects.UnitGlobalDisabled()
ctx.Data["UnitActionsGlobalDisabled"] = unit.TypeActions.UnitGlobalDisabled()
ctx.Data["AllLangs"] = translation.AllLangs()
ctx.PageData["PLURAL_RULE_LANG"] = translation.GetPluralRule(ctx.Locale)
ctx.PageData["PLURAL_RULE_FALLBACK"] = translation.GetDefaultPluralRule()
ctx.PageData["PLURALSTRINGS_LANG"] = map[string][]string{}
ctx.PageData["PLURALSTRINGS_FALLBACK"] = map[string][]string{}
ctx.AddPluralStringsToPageData([]string{"relativetime.mins", "relativetime.hours", "relativetime.days", "relativetime.weeks", "relativetime.months", "relativetime.years"})
ctx.PageData["DATETIMESTRINGS"] = map[string]string{
"FUTURE": ctx.Locale.TrString("relativetime.future"),
"NOW": ctx.Locale.TrString("relativetime.now"),
}
for _, key := range []string{"relativetime.1day", "relativetime.1week", "relativetime.1month", "relativetime.1year"} {
// These keys are used for special-casing some time words. We only add keys that are actually translated, so that we
// can fall back to the generic pluralized time word in the correct language if the special case is untranslated.
if ctx.Locale.HasKey(key) {
ctx.PageData["DATETIMESTRINGS"].(map[string]string)[key] = ctx.Locale.TrString(key)
}
}
next.ServeHTTP(ctx.Resp, ctx.Req)
})
}
}
// HasError returns true if error occurs in form validation.
// Attention: this function changes ctx.Data and ctx.Flash
func (ctx *Context) HasError() bool {
hasErr, ok := ctx.Data["HasError"]
if !ok {
return false
}
ctx.Flash.ErrorMsg = ctx.GetErrMsg()
ctx.Data["Flash"] = ctx.Flash
return hasErr.(bool)
}
// GetErrMsg returns error message in form validation.
func (ctx *Context) GetErrMsg() string {
msg, _ := ctx.Data["ErrorMsg"].(string)
if msg == "" {
msg = "invalid form data"
}
return msg
}
func (ctx *Context) JSONRedirect(redirect string) {
ctx.JSON(http.StatusOK, map[string]any{"redirect": redirect})
}
func (ctx *Context) JSONOK() {
ctx.JSON(http.StatusOK, map[string]any{"ok": true}) // this is only a dummy response, frontend seldom uses it
}
func (ctx *Context) JSONError(msg any) {
switch v := msg.(type) {
case string:
ctx.JSON(http.StatusBadRequest, map[string]any{"errorMessage": v, "renderFormat": "text"})
case template.HTML:
ctx.JSON(http.StatusBadRequest, map[string]any{"errorMessage": v, "renderFormat": "html"})
default:
panic(fmt.Sprintf("unsupported type: %T", msg))
}
}