jojo/models/forgejo_migrations/v14a_ap-change-fedi-handle-structure.go
Mathieu Fenniak 8387974e2e ci: prevent usage of live application models & services in migrations (#11872)
Prevent access to "current" application models and services from migrations via `golangci` config:

eg:
```
models/forgejo_migrations/v14a_ap-change-fedi-handle-structure.go:18:2: import 'forgejo.org/models/user' is not allowed from list 'migration-isolation': Migrations must not import application models. Application models will be the most recent schema for Forgejo, while migrations will be operating against the database schema that existed when they were authored. (depguard)
	user_model "forgejo.org/models/user"
	^
models/forgejo_migrations/v14a_ap-change-fedi-handle-structure.go:21:2: import 'forgejo.org/services/user' is not allowed from list 'migration-isolation': Migrations must not import application services. Application services will reference application models which will use the most recent schema for Forgejo, while migrations will be operating against the database schema that existed when they were authored. (depguard)
	user_service "forgejo.org/services/user"
```

Fixes an existing migration issue where it isn't possible to add a new column to the `User` table ([test errors that occur](https://codeberg.org/forgejo/forgejo/actions/runs/148633/jobs/10/attempt/1#jobstep-5-323)), but also guarantees that future migrations don't stumble into the same issue by inadvertently referencing live application code from historical migrations.

Originally identified and draft fix by @codecat w/ proposed fix in #11870.

## Checklist

The [contributor guide](https://forgejo.org/docs/next/contributor/) contains information that will be helpful to first time contributors. All work and communication must conform to Forgejo's [AI Agreement](https://codeberg.org/forgejo/governance/src/branch/main/AIAgreement.md). There also are a few [conditions for merging Pull Requests in Forgejo repositories](https://codeberg.org/forgejo/governance/src/branch/main/PullRequestsAgreement.md). You are also welcome to join the [Forgejo development chatroom](https://matrix.to/#/#forgejo-development:matrix.org).

### Tests for Go changes

- I added test coverage for Go changes...
  - [ ] in their respective `*_test.go` for unit tests.
  - [ ] in the `tests/integration` directory if it involves interactions with a live Forgejo server.
- I ran...
  - [x] `make pr-go` before pushing

### Documentation

- [ ] I created a pull request [to the documentation](https://codeberg.org/forgejo/docs) to explain to Forgejo users how to use this change.
- [x] I did not document these changes and I do not expect someone else to do it.

### Release notes

- [ ] This change will be noticed by a Forgejo user or admin (feature, bug fix, performance, etc.). I suggest to include a release note for this change.
- [x] This change is not visible to a Forgejo user or admin (refactor, dependency upgrade, etc.). I think there is no need to add a release note for this change.

Co-authored-by: Melissa Geels <melissa@nimble.tools>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/11872
Reviewed-by: Andreas Ahlenstorf <aahlenst@noreply.codeberg.org>
Reviewed-by: Michael Kriese <michael.kriese@gmx.de>
Co-authored-by: Mathieu Fenniak <mathieu@fenniak.net>
Co-committed-by: Mathieu Fenniak <mathieu@fenniak.net>
2026-03-31 02:56:43 +02:00

179 lines
8.2 KiB
Go

// Copyright 2025 The Forgejo Authors. All rights reserved.
// SPDX-License-Identifier: GPL-3.0-or-later
// Due to a mistake during code review, this code was merged with the prefix 14a
// but this code was merged for the v15 cycle, the correct prefix would be 15a.
// As it would lead to breakage for instance who already ran with the old prefix
// the incorrect prefix is kept.
package forgejo_migrations
import (
"context"
"database/sql"
"fmt"
"strings"
"forgejo.org/models/db"
"forgejo.org/modules/log"
"forgejo.org/modules/timeutil"
"forgejo.org/modules/validation"
"xorm.io/xorm"
)
func init() {
registerMigration(&Migration{
Description: "use structure @PreferredUsername@host.tld:port for actors",
Upgrade: changeActivityPubUsernameFormat,
})
}
func changeActivityPubUsernameFormat(x *xorm.Engine) error {
type FederationHost struct {
ID int64 `xorm:"pk autoincr"`
HostFqdn string `xorm:"host_fqdn UNIQUE(federation_host) INDEX VARCHAR(255) NOT NULL"`
HostPort uint16 `xorm:" UNIQUE(federation_host) INDEX NOT NULL DEFAULT 443"`
HostSchema string `xorm:"NOT NULL DEFAULT 'https'"`
Created timeutil.TimeStamp `xorm:"created"`
Updated timeutil.TimeStamp `xorm:"updated"`
}
type FederatedUser struct {
ID int64 `xorm:"pk autoincr"`
UserID int64 `xorm:"NOT NULL INDEX user_id"`
ExternalID string `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
FederationHostID int64 `xorm:"UNIQUE(federation_user_mapping) NOT NULL"`
KeyID sql.NullString `xorm:"key_id UNIQUE"`
PublicKey sql.Null[sql.RawBytes] `xorm:"BLOB"`
InboxPath string
NormalizedOriginalURL string // This field is just to keep original information. Pls. do not use for search or as ID!
}
type User struct {
ID int64 `xorm:"pk autoincr"`
LowerName string `xorm:"UNIQUE NOT NULL"`
Name string `xorm:"UNIQUE NOT NULL"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX created"`
UpdatedUnix timeutil.TimeStamp `xorm:"INDEX updated"`
}
deleteFederatedUser := func(ctx context.Context, userID int64) error {
_, err := db.GetEngine(ctx).Delete(&FederatedUser{UserID: userID})
return err
}
userLogString := func(u *User) string {
if u == nil {
return "<User nil>"
}
return fmt.Sprintf("<User %d:%s>", u.ID, u.Name)
}
// Normally, the db.WithTx statement ensures that the database transaction (aka. all changes made
// by this migration) will only be committed if the SQL operations inside of the iteration
// (db.Iterate) don't return an error.
//
// This migration was originally authored with those cases in mind, but it was later agreed that
// migrations concerning Forgejo's federation-related components should not return any errors at
// this point in time, as federation is not considered to be stable at the moment. For more
// information, check the relevant discussion here:
// https://codeberg.org/forgejo-contrib/federation/issues/67
//
// Nevertheless, this structure involves some useful boilerplate that can be used for future
// migrations at a later point and has been kept as-is.
return db.WithTx(db.DefaultContext, func(ctx context.Context) error {
// The transaction is committed only if modifying all federated users is possible.
return db.Iterate(ctx, nil, func(ctx context.Context, federatedUser *FederatedUser) error {
// localUser represents the "local" representation of an ActivityPub (federated) user
localUser := &User{}
has, err := db.GetEngine(ctx).ID(federatedUser.UserID).Get(localUser)
if err != nil {
log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred while getting local user (ID: %d), ignoring...: %e", federatedUser.UserID, err)
return nil
}
if !has {
log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: User missing for federated user: %v", federatedUser)
err := deleteFederatedUser(ctx, federatedUser.UserID)
if err != nil {
log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred while deleting federated user (%s), ignoring...: %e", federatedUser, err)
return nil
}
}
if validation.IsValidActivityPubUsername(localUser.Name) {
log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: FederatedUser was already migrated: %v", federatedUser)
} else {
// Copied from models/forgefed/federationhost_repository.go (forgefed.GetFederationHost),
// minus some validation code for FederationHost which we do not otherwise manipulate here.
federationHost := new(FederationHost)
has, err := db.GetEngine(ctx).ID(federatedUser.FederationHostID).Get(federationHost)
if err != nil {
log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred while looking up federation host info (for %v), ignoring...: %e", federatedUser, err)
return nil
} else if !has {
log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Federation host for federated user %s is missing", federatedUser)
return nil
}
// Take part of the username before the first dash, reconstruct the rest
// of it using whatever we have in FederationHost. Before this migration,
// usernames of ActivityPub accounts have an expected format of
// "username-subdomain-domain-tld-port". We don't know how many subdomains
// there are, but that doesn't matter. We can always get the username unless
// if the username of an ActivityPub account was manually changed by an admin,
// in which case they should either delete the account or change it back.
s := strings.Split(localUser.Name, "-")
if len(s) == 0 {
log.Warn(
"Migration[v14a_ap-change-fedi-handle-structure]: Username %s belonging to federatedUser %v does not contain any dashes, can't construct new username",
localUser.Name,
federatedUser,
)
return nil
}
// Were a running Forgejo instance to create a new federated account, would the port
// have been marked as "supplemented" (thus leading to its omission)?
var newUsername string
if (federationHost.HostPort == 443 && federationHost.HostSchema == "https") || (federationHost.HostPort == 80 && federationHost.HostSchema == "http") {
newUsername = fmt.Sprintf("@%s@%s", s[0], federationHost.HostFqdn)
} else {
newUsername = fmt.Sprintf("@%s@%s:%d", s[0], federationHost.HostFqdn, federationHost.HostPort)
}
// Implicitly assumes that there won't be a lower name unique constraint violation.
// Potentially a bit paranoid, but why not?
userThatShouldntExist := &User{}
lowernameTaken, err := db.GetEngine(ctx).Where("lower_name = ?", strings.ToLower(newUsername)).Table("user").Get(userThatShouldntExist)
if err != nil {
log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred, skipping migration of %s: %e", userLogString(localUser), err)
return nil
}
if lowernameTaken {
log.Warn(
"Migration[v14a_ap-change-fedi-handle-structure]: New username %s for %s already taken by %s, deleting the former...",
newUsername,
userLogString(localUser),
userLogString(userThatShouldntExist),
)
err := deleteFederatedUser(ctx, localUser.ID)
if err != nil {
log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred while deleting federated user (%s), ignoring...: %e", userLogString(localUser), err)
}
return nil
}
// Safe to assume that the following operations should just work now.
log.Info("Migration[v14a_ap-change-fedi-handle-structure]: Updating username of %s to %s", userLogString(localUser), newUsername)
if _, err := db.GetEngine(ctx).ID(localUser.ID).Cols("lower_name", "name").Update(&User{
LowerName: strings.ToLower(newUsername),
Name: newUsername,
}); err != nil {
log.Warn("Migration[v14a_ap-change-fedi-handle-structure]: Database error occurred when updating federated user's username (%s), ignoring...: %e", userLogString(localUser), err)
return nil
}
}
return nil
})
})
}