summaryrefslogtreecommitdiff
path: root/internal/db/bundb/migrations/20250715095446_int_pols_forward_compat.go
blob: 0df51508212210230ba975e102d1632897e1a73f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
// GoToSocial
// Copyright (C) GoToSocial Authors admin@gotosocial.org
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <http://www.gnu.org/licenses/>.

package migrations

import (
	"context"
	"database/sql"
	"errors"
	"net/url"
	"strings"

	"code.superseriousbusiness.org/gotosocial/internal/config"
	"code.superseriousbusiness.org/gotosocial/internal/gtserror"
	"code.superseriousbusiness.org/gotosocial/internal/id"
	"code.superseriousbusiness.org/gotosocial/internal/log"
	"code.superseriousbusiness.org/gotosocial/internal/util"
	"github.com/uptrace/bun"
	"github.com/uptrace/bun/dialect"

	new_gtsmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20250715095446_int_pols_forward_compat/new"
	old_gtsmodel "code.superseriousbusiness.org/gotosocial/internal/db/bundb/migrations/20250715095446_int_pols_forward_compat/old"
)

func init() {
	up := func(ctx context.Context, db *bun.DB) error {
		const tmpTableName = "new_interaction_requests"
		const tableName = "interaction_requests"
		var host = config.GetHost()
		var accountDomain = config.GetAccountDomain()

		// Count number of interaction
		// requests we need to update.
		total, err := db.NewSelect().
			Table(tableName).
			Count(ctx)
		if err != nil {
			return gtserror.Newf("error geting interaction requests table count: %w", err)
		}

		// Create new interaction_requests table and convert all existing into it.
		if err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {

			log.Info(ctx, "creating new interaction_requests table")
			if _, err := tx.NewCreateTable().
				ModelTableExpr(tmpTableName).
				Model((*new_gtsmodel.InteractionRequest)(nil)).
				Exec(ctx); err != nil {
				return gtserror.Newf("error creating new interaction requests table: %w", err)
			}

			// Conversion batch size.
			const batchsz = 1000

			var maxID string
			var count int

			// Start at largest
			// possible ULID value.
			maxID = id.Highest

			// Preallocate interaction request slices to maximum possible size.
			oldRequests := make([]*old_gtsmodel.InteractionRequest, 0, batchsz)
			newRequests := make([]*new_gtsmodel.InteractionRequest, 0, batchsz)

			log.Info(ctx, "migrating interaction requests to new table, this may take some time!")
		outer:
			for {
				// Reset slices slices.
				clear(oldRequests)
				clear(newRequests)
				oldRequests = oldRequests[:0]
				newRequests = newRequests[:0]

				// Select next batch of
				// interaction requests.
				if err := tx.NewSelect().
					Model(&oldRequests).
					Where("? < ?", bun.Ident("id"), maxID).
					OrderExpr("? DESC", bun.Ident("id")).
					Limit(batchsz).
					Scan(ctx); err != nil && !errors.Is(err, sql.ErrNoRows) {
					return gtserror.Newf("error selecting interaction requests: %w", err)
				}

				// Reached end of requests.
				if len(oldRequests) == 0 {
					break outer
				}

				// Set next maxID value from old requests.
				maxID = oldRequests[len(oldRequests)-1].ID

			inner:
				// Convert old request models to new.
				for _, oldRequest := range oldRequests {
					newRequest := &new_gtsmodel.InteractionRequest{
						ID:                   oldRequest.ID,
						TargetStatusID:       oldRequest.StatusID,
						TargetAccountID:      oldRequest.TargetAccountID,
						InteractingAccountID: oldRequest.InteractingAccountID,
						InteractionURI:       oldRequest.InteractionURI,
						InteractionType:      int16(oldRequest.InteractionType), // #nosec G115
						Polite:               util.Ptr(false),                   // old requests were always impolite
						AcceptedAt:           oldRequest.AcceptedAt,
						RejectedAt:           oldRequest.RejectedAt,
						ResponseURI:          oldRequest.URI,
					}

					// Append new request to slice,
					// though we continue operating on
					// its ptr in the rest of this loop.
					newRequests = append(newRequests,
						newRequest)

					// Re-use the original interaction URI to create
					// a mock interaction request URI on the new model.
					switch oldRequest.InteractionType {
					case old_gtsmodel.InteractionLike:
						newRequest.InteractionRequestURI = oldRequest.InteractionURI + new_gtsmodel.LikeRequestSuffix
					case old_gtsmodel.InteractionReply:
						newRequest.InteractionRequestURI = oldRequest.InteractionURI + new_gtsmodel.ReplyRequestSuffix
					case old_gtsmodel.InteractionAnnounce:
						newRequest.InteractionRequestURI = oldRequest.InteractionURI + new_gtsmodel.AnnounceRequestSuffix
					}

					// If the request was accepted by us, then generate an authorization
					// URI for it, in order to be able to serve an Authorization if necessary.
					if oldRequest.AcceptedAt.IsZero() || oldRequest.URI == "" {

						// Wasn't accepted,
						// nothing else to do.
						continue inner
					}

					// Parse URI details of accept URI string.
					acceptURI, err := url.Parse(oldRequest.URI)
					if err != nil {
						log.Warnf(ctx, "could not parse oldRequest.URI for interaction request %s,"+
							" skipping forward-compat hack (don't worry, this is not a big deal): %v",
							oldRequest.ID, err)
						continue inner
					}

					// Check whether accept URI originated from this instance.
					if !(acceptURI.Host == host || acceptURI.Host == accountDomain) {

						// Not an accept from
						// us, leave it alone.
						continue inner
					}

					// Reuse the Accept URI to create an Authorization URI.
					// Creates `https://example.org/users/aaa/authorizations/[ID]`
					// from `https://example.org/users/aaa/accepts/[ID]`.
					authorizationURI := strings.ReplaceAll(
						oldRequest.URI,
						"/accepts/"+oldRequest.ID,
						"/authorizations/"+oldRequest.ID,
					)
					newRequest.AuthorizationURI = authorizationURI

					var updateTableName string

					// Determine which table will have corresponding approved_by_uri.
					if oldRequest.InteractionType == old_gtsmodel.InteractionLike {
						updateTableName = "status_faves"
					} else {
						updateTableName = "statuses"
					}

					// Update the corresponding interaction
					// with generated authorization URI.
					if _, err := tx.NewUpdate().
						Table(updateTableName).
						Set("? = ?", bun.Ident("approved_by_uri"), authorizationURI).
						Where("? = ?", bun.Ident("uri"), oldRequest.InteractionURI).
						Exec(ctx); err != nil {
						return gtserror.Newf("error updating approved_by_uri: %w", err)
					}
				}

				// Insert converted interaction
				// request models to new table.
				if _, err := tx.
					NewInsert().
					Model(&newRequests).
					Exec(ctx); err != nil {
					return gtserror.Newf("error inserting interaction requests: %w", err)
				}

				// Increment insert count.
				count += len(newRequests)

				log.Infof(ctx, "[%d of %d] converting interaction requests", count, total)
			}

			return nil
		}); err != nil {
			return err
		}

		// Ensure that the above transaction
		// has gone ahead without issues.
		//
		// Also placing this here might make
		// breaking this into piecemeal steps
		// easier if turns out necessary.
		newTotal, err := db.NewSelect().
			Table(tmpTableName).
			Count(ctx)
		if err != nil {
			return gtserror.Newf("error geting new interaction requests table count: %w", err)
		} else if total != newTotal {
			return gtserror.Newf("new interaction requests table contains unexpected count %d, want %d", newTotal, total)
		}

		// Attempt to merge any sqlite write-ahead-log.
		if err := doWALCheckpoint(ctx, db); err != nil {
			return err
		}

		// Drop the old interaction requests table and rename new one to replace it.
		if err := db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error {

			log.Info(ctx, "dropping old interaction_requests table")
			if _, err := tx.NewDropTable().
				Table(tableName).
				Exec(ctx); err != nil {
				return gtserror.Newf("error dropping old interaction requests table: %w", err)
			}

			log.Info(ctx, "renaming new interaction_requests table to old")
			if _, err := tx.NewRaw("ALTER TABLE ? RENAME TO ?",
				bun.Ident(tmpTableName),
				bun.Ident(tableName),
			).Exec(ctx); err != nil {
				return gtserror.Newf("error renaming interaction requests table: %w", err)
			}

			// Create necessary indices on the new table.
			for index, columns := range map[string][]string{
				"interaction_requests_target_status_id_idx":       {"target_status_id"},
				"interaction_requests_interacting_account_id_idx": {"interacting_account_id"},
				"interaction_requests_target_account_id_idx":      {"target_account_id"},
				"interaction_requests_accepted_at_idx":            {"accepted_at"},
				"interaction_requests_rejected_at_idx":            {"rejected_at"},
			} {
				log.Infof(ctx, "recreating %s index", index)
				if _, err := tx.NewCreateIndex().
					Table(tableName).
					Index(index).
					Column(columns...).
					Exec(ctx); err != nil {
					return err
				}
			}

			if tx.Dialect().Name() == dialect.PG {
				// Rename postgres uniqueness constraints:
				// "new_interaction_requests_*" -> "interaction_requests_*"
				log.Info(ctx, "renaming interaction_requests constraints on new table")
				for _, spec := range []struct {
					old string
					new string
				}{
					{
						old: "new_interaction_requests_pkey",
						new: "interaction_requests_pkey",
					},
					{
						old: "new_interaction_requests_interaction_request_uri_key",
						new: "interaction_requests_interaction_request_uri_key",
					},
					{
						old: "new_interaction_requests_interaction_uri_key",
						new: "interaction_requests_interaction_uri_key",
					},
					{
						old: "new_interaction_requests_response_uri_key",
						new: "interaction_requests_response_uri_key",
					},
					{
						old: "new_interaction_requests_authorization_uri_key",
						new: "interaction_requests_authorization_uri_key",
					},
				} {
					if _, err := tx.NewRaw("ALTER TABLE ? RENAME CONSTRAINT ? TO ?",
						bun.Ident(tableName),
						bun.Safe(spec.old),
						bun.Safe(spec.new),
					).Exec(ctx); err != nil {
						return gtserror.Newf("error renaming postgres interaction requests constraint %s: %w", spec.new, err)
					}
				}
			}

			return nil
		}); err != nil {
			return err
		}

		// Final sqlite write-ahead-log merge.
		return doWALCheckpoint(ctx, db)
	}

	down := func(ctx context.Context, db *bun.DB) error {
		return nil
	}

	if err := Migrations.Register(up, down); err != nil {
		panic(err)
	}
}