mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 13:32:25 -05:00 
			
		
		
		
	[feature] add support for receiving federated status edits (#3597)
* add support for extracting Updated field from Statusable implementers
* add support for status edits in the database, and update status dereferencer to handle them
* remove unused AdditionalInfo{}.CreatedAt
* remove unused AdditionalEmojiInfo{}.CreatedAt
* update new mention creation to use status.UpdatedAt
* remove mention.UpdatedAt, fixes related to NewULIDFromTime() change
* add migration to remove Mention{}.UpdatedAt field
* add migration to add the StatusEdit{} table
* start adding tests, add delete function for status edits
* add more of status edit migrations, fill in more of the necessary edit delete functionality
* remove unused function
* allow generating gotosocial compatible ulid via CLI with `go run ./cmd/gen-ulid`
* add StatusEdit{} test models
* fix new statusedits sql
* use model instead of table name
* actually remove the Mention.UpdatedAt field...
* fix tests now new models are added, add more status edit DB tests
* fix panic wording
* add test for deleting status edits
* don't automatically set `updated_at` field on updated statuses
* flesh out more of the dereferencer status edit tests, ensure updated at field set on outgoing AS statuses
* remove media_attachments.updated_at column
* fix up more tests, further complete the dereferencer status edit tests
* update more status serialization tests not expecting 'updated' AS property
* gah!! json serialization tests!!
* undo some gtscontext wrapping changes
* more serialization test fixing 🥲
* more test fixing, ensure the edit.status_id field is actually set 🤦
* fix status edit test
* grrr linter
* add edited_at field to apimodel status
* remove the choice of paging on the timeline public filtered test (otherwise it needs updating every time you add statuses ...)
* ensure that status.updated_at always fits chronologically
* fix more serialization tests ...
* add more code comments
* fix envparsing
* update swagger file
* properly handle media description changes during status edits
* slight formatting tweak
* code comment
	
	
This commit is contained in:
		
					parent
					
						
							
								3e18d97a6e
							
						
					
				
			
			
				commit
				
					
						23fc70f4e6
					
				
			
		
					 86 changed files with 2557 additions and 651 deletions
				
			
		
							
								
								
									
										22
									
								
								cmd/gen-ulid/main.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								cmd/gen-ulid/main.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,22 @@ | ||||||
|  | // 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 main | ||||||
|  | 
 | ||||||
|  | import "github.com/superseriousbusiness/gotosocial/internal/id" | ||||||
|  | 
 | ||||||
|  | func main() { println(id.NewULID()) } | ||||||
|  | @ -2692,6 +2692,11 @@ definitions: | ||||||
|                 example: "2021-07-30T09:20:25+00:00" |                 example: "2021-07-30T09:20:25+00:00" | ||||||
|                 type: string |                 type: string | ||||||
|                 x-go-name: CreatedAt |                 x-go-name: CreatedAt | ||||||
|  |             edited_at: | ||||||
|  |                 description: Timestamp of when the status was last edited (ISO 8601 Datetime). | ||||||
|  |                 example: "2021-07-30T09:20:25+00:00" | ||||||
|  |                 type: string | ||||||
|  |                 x-go-name: EditedAt | ||||||
|             emojis: |             emojis: | ||||||
|                 description: Custom emoji to be used when rendering status content. |                 description: Custom emoji to be used when rendering status content. | ||||||
|                 items: |                 items: | ||||||
|  | @ -2889,6 +2894,11 @@ definitions: | ||||||
|                 example: "2021-07-30T09:20:25+00:00" |                 example: "2021-07-30T09:20:25+00:00" | ||||||
|                 type: string |                 type: string | ||||||
|                 x-go-name: CreatedAt |                 x-go-name: CreatedAt | ||||||
|  |             edited_at: | ||||||
|  |                 description: Timestamp of when the status was last edited (ISO 8601 Datetime). | ||||||
|  |                 example: "2021-07-30T09:20:25+00:00" | ||||||
|  |                 type: string | ||||||
|  |                 x-go-name: EditedAt | ||||||
|             emojis: |             emojis: | ||||||
|                 description: Custom emoji to be used when rendering status content. |                 description: Custom emoji to be used when rendering status content. | ||||||
|                 items: |                 items: | ||||||
|  |  | ||||||
|  | @ -25,8 +25,11 @@ import ( | ||||||
| 
 | 
 | ||||||
| // IsActivityable returns whether AS vocab type name is acceptable as Activityable. | // IsActivityable returns whether AS vocab type name is acceptable as Activityable. | ||||||
| func IsActivityable(typeName string) bool { | func IsActivityable(typeName string) bool { | ||||||
| 	return isActivity(typeName) || | 	return isActivity(typeName) | ||||||
| 		isIntransitiveActivity(typeName) | 	// See interfaces_test.go comment | ||||||
|  | 	// about intransitive activities: | ||||||
|  | 	// | ||||||
|  | 	// || isIntransitiveActivity(typeName) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // ToActivityable safely tries to cast vocab.Type as Activityable, also checking for expected AS type names. | // ToActivityable safely tries to cast vocab.Type as Activityable, also checking for expected AS type names. | ||||||
|  | @ -184,6 +187,7 @@ type Accountable interface { | ||||||
| 	WithEndpoints | 	WithEndpoints | ||||||
| 	WithTag | 	WithTag | ||||||
| 	WithPublished | 	WithPublished | ||||||
|  | 	WithUpdated | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // Statusable represents the minimum activitypub interface for representing a 'status'. | // Statusable represents the minimum activitypub interface for representing a 'status'. | ||||||
|  | @ -196,6 +200,7 @@ type Statusable interface { | ||||||
| 	WithName | 	WithName | ||||||
| 	WithInReplyTo | 	WithInReplyTo | ||||||
| 	WithPublished | 	WithPublished | ||||||
|  | 	WithUpdated | ||||||
| 	WithURL | 	WithURL | ||||||
| 	WithAttributedTo | 	WithAttributedTo | ||||||
| 	WithTo | 	WithTo | ||||||
|  |  | ||||||
							
								
								
									
										93
									
								
								internal/ap/interfaces_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								internal/ap/interfaces_test.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,93 @@ | ||||||
|  | // 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 ap_test | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"github.com/superseriousbusiness/activity/streams/vocab" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/ap" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | var ( | ||||||
|  | 	// NOTE: the below aren't actually tests that are run, | ||||||
|  | 	// we just move them into an _test.go file to declutter | ||||||
|  | 	// the main interfaces.go file, which is already long. | ||||||
|  | 
 | ||||||
|  | 	// Compile-time checks for Activityable interface methods. | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsAccept)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsTentativeAccept)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsAdd)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsCreate)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsDelete)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsFollow)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsIgnore)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsJoin)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsLeave)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsLike)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsOffer)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsInvite)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsReject)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsTentativeReject)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsRemove)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsUndo)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsUpdate)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsView)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsListen)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsRead)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsMove)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsAnnounce)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsBlock)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsFlag)(nil) | ||||||
|  | 	_ ap.Activityable = (vocab.ActivityStreamsDislike)(nil) | ||||||
|  | 
 | ||||||
|  | 	// the below intransitive activities don't fit the interface definition because they're | ||||||
|  | 	// missing an attached object (as the activity itself contains the details), but we don't | ||||||
|  | 	// actually end up using them so it's  simpler to just comment them out and not have to do | ||||||
|  | 	// a WithObject{} interface check on every single incoming activity: | ||||||
|  | 	// | ||||||
|  | 	// _ Activityable = (vocab.ActivityStreamsArrive)(nil) | ||||||
|  | 	// _ Activityable = (vocab.ActivityStreamsTravel)(nil) | ||||||
|  | 	// _ Activityable = (vocab.ActivityStreamsQuestion)(nil) | ||||||
|  | 
 | ||||||
|  | 	// Compile-time checks for Accountable interface methods. | ||||||
|  | 	_ ap.Accountable = (vocab.ActivityStreamsPerson)(nil) | ||||||
|  | 	_ ap.Accountable = (vocab.ActivityStreamsApplication)(nil) | ||||||
|  | 	_ ap.Accountable = (vocab.ActivityStreamsOrganization)(nil) | ||||||
|  | 	_ ap.Accountable = (vocab.ActivityStreamsService)(nil) | ||||||
|  | 	_ ap.Accountable = (vocab.ActivityStreamsGroup)(nil) | ||||||
|  | 
 | ||||||
|  | 	// Compile-time checks for Statusable interface methods. | ||||||
|  | 	_ ap.Statusable = (vocab.ActivityStreamsArticle)(nil) | ||||||
|  | 	_ ap.Statusable = (vocab.ActivityStreamsDocument)(nil) | ||||||
|  | 	_ ap.Statusable = (vocab.ActivityStreamsImage)(nil) | ||||||
|  | 	_ ap.Statusable = (vocab.ActivityStreamsVideo)(nil) | ||||||
|  | 	_ ap.Statusable = (vocab.ActivityStreamsNote)(nil) | ||||||
|  | 	_ ap.Statusable = (vocab.ActivityStreamsPage)(nil) | ||||||
|  | 	_ ap.Statusable = (vocab.ActivityStreamsEvent)(nil) | ||||||
|  | 	_ ap.Statusable = (vocab.ActivityStreamsPlace)(nil) | ||||||
|  | 	_ ap.Statusable = (vocab.ActivityStreamsProfile)(nil) | ||||||
|  | 	_ ap.Statusable = (vocab.ActivityStreamsQuestion)(nil) | ||||||
|  | 
 | ||||||
|  | 	// Compile-time checks for Pollable interface methods. | ||||||
|  | 	_ ap.Pollable = (vocab.ActivityStreamsQuestion)(nil) | ||||||
|  | 
 | ||||||
|  | 	// Compile-time checks for PollOptionable interface methods. | ||||||
|  | 	_ ap.PollOptionable = (vocab.ActivityStreamsNote)(nil) | ||||||
|  | 
 | ||||||
|  | 	// Compile-time checks for Acceptable interface methods. | ||||||
|  | 	_ ap.Acceptable = (vocab.ActivityStreamsAccept)(nil) | ||||||
|  | ) | ||||||
|  | @ -408,6 +408,25 @@ func SetPublished(with WithPublished, published time.Time) { | ||||||
| 	publishProp.Set(published) | 	publishProp.Set(published) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // GetUpdated returns the time contained in the Updated property of 'with'. | ||||||
|  | func GetUpdated(with WithUpdated) time.Time { | ||||||
|  | 	updateProp := with.GetActivityStreamsUpdated() | ||||||
|  | 	if updateProp == nil || !updateProp.IsXMLSchemaDateTime() { | ||||||
|  | 		return time.Time{} | ||||||
|  | 	} | ||||||
|  | 	return updateProp.Get() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SetUpdated sets the given time on the Updated property of 'with'. | ||||||
|  | func SetUpdated(with WithUpdated, updated time.Time) { | ||||||
|  | 	updateProp := with.GetActivityStreamsUpdated() | ||||||
|  | 	if updateProp == nil { | ||||||
|  | 		updateProp = streams.NewActivityStreamsUpdatedProperty() | ||||||
|  | 		with.SetActivityStreamsUpdated(updateProp) | ||||||
|  | 	} | ||||||
|  | 	updateProp.Set(updated) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // GetEndTime returns the time contained in the EndTime property of 'with'. | // GetEndTime returns the time contained in the EndTime property of 'with'. | ||||||
| func GetEndTime(with WithEndTime) time.Time { | func GetEndTime(with WithEndTime) time.Time { | ||||||
| 	endTimeProp := with.GetActivityStreamsEndTime() | 	endTimeProp := with.GetActivityStreamsEndTime() | ||||||
|  |  | ||||||
|  | @ -82,7 +82,7 @@ func (suite *OutboxGetTestSuite) TestGetOutbox() { | ||||||
|   "@context": "https://www.w3.org/ns/activitystreams", |   "@context": "https://www.w3.org/ns/activitystreams", | ||||||
|   "first": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40", |   "first": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40", | ||||||
|   "id": "http://localhost:8080/users/the_mighty_zork/outbox", |   "id": "http://localhost:8080/users/the_mighty_zork/outbox", | ||||||
|   "totalItems": 8, |   "totalItems": 9, | ||||||
|   "type": "OrderedCollection" |   "type": "OrderedCollection" | ||||||
| }`, dst.String()) | }`, dst.String()) | ||||||
| 
 | 
 | ||||||
|  | @ -142,6 +142,14 @@ func (suite *OutboxGetTestSuite) TestGetOutboxFirstPage() { | ||||||
|   "id": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40", |   "id": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40", | ||||||
|   "next": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40\u0026max_id=01F8MHAMCHF6Y650WCRSCP4WMY", |   "next": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40\u0026max_id=01F8MHAMCHF6Y650WCRSCP4WMY", | ||||||
|   "orderedItems": [ |   "orderedItems": [ | ||||||
|  |     { | ||||||
|  |       "actor": "http://localhost:8080/users/the_mighty_zork", | ||||||
|  |       "cc": "http://localhost:8080/users/the_mighty_zork/followers", | ||||||
|  |       "id": "http://localhost:8080/users/the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR/activity#Create", | ||||||
|  |       "object": "http://localhost:8080/users/the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR", | ||||||
|  |       "to": "https://www.w3.org/ns/activitystreams#Public", | ||||||
|  |       "type": "Create" | ||||||
|  |     }, | ||||||
|     { |     { | ||||||
|       "actor": "http://localhost:8080/users/the_mighty_zork", |       "actor": "http://localhost:8080/users/the_mighty_zork", | ||||||
|       "cc": "http://localhost:8080/users/the_mighty_zork/followers", |       "cc": "http://localhost:8080/users/the_mighty_zork/followers", | ||||||
|  | @ -160,8 +168,8 @@ func (suite *OutboxGetTestSuite) TestGetOutboxFirstPage() { | ||||||
|     } |     } | ||||||
|   ], |   ], | ||||||
|   "partOf": "http://localhost:8080/users/the_mighty_zork/outbox", |   "partOf": "http://localhost:8080/users/the_mighty_zork/outbox", | ||||||
|   "prev": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40\u0026min_id=01HH9KYNQPA416TNJ53NSATP40", |   "prev": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40\u0026min_id=01JDPZC707CKDN8N4QVWM4Z1NR", | ||||||
|   "totalItems": 8, |   "totalItems": 9, | ||||||
|   "type": "OrderedCollectionPage" |   "type": "OrderedCollectionPage" | ||||||
| }`, dst.String()) | }`, dst.String()) | ||||||
| 
 | 
 | ||||||
|  | @ -224,7 +232,7 @@ func (suite *OutboxGetTestSuite) TestGetOutboxNextPage() { | ||||||
|   "id": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40&max_id=01F8MHAMCHF6Y650WCRSCP4WMY", |   "id": "http://localhost:8080/users/the_mighty_zork/outbox?limit=40&max_id=01F8MHAMCHF6Y650WCRSCP4WMY", | ||||||
|   "orderedItems": [], |   "orderedItems": [], | ||||||
|   "partOf": "http://localhost:8080/users/the_mighty_zork/outbox", |   "partOf": "http://localhost:8080/users/the_mighty_zork/outbox", | ||||||
|   "totalItems": 8, |   "totalItems": 9, | ||||||
|   "type": "OrderedCollectionPage" |   "type": "OrderedCollectionPage" | ||||||
| }`, dst.String()) | }`, dst.String()) | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -97,7 +97,7 @@ func (suite *AccountVerifyTestSuite) TestAccountVerifyGet() { | ||||||
| 	suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", apimodelAccount.HeaderStatic) | 	suite.Equal("http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/small/01PFPMWK2FF0D9WMHEJHR07C3Q.webp", apimodelAccount.HeaderStatic) | ||||||
| 	suite.Equal(2, apimodelAccount.FollowersCount) | 	suite.Equal(2, apimodelAccount.FollowersCount) | ||||||
| 	suite.Equal(2, apimodelAccount.FollowingCount) | 	suite.Equal(2, apimodelAccount.FollowingCount) | ||||||
| 	suite.Equal(8, apimodelAccount.StatusesCount) | 	suite.Equal(9, apimodelAccount.StatusesCount) | ||||||
| 	suite.EqualValues(apimodel.VisibilityPublic, apimodelAccount.Source.Privacy) | 	suite.EqualValues(apimodel.VisibilityPublic, apimodelAccount.Source.Privacy) | ||||||
| 	suite.Equal(testAccount.Settings.Language, apimodelAccount.Source.Language) | 	suite.Equal(testAccount.Settings.Language, apimodelAccount.Source.Language) | ||||||
| 	suite.Equal(testAccount.NoteRaw, apimodelAccount.Source.Note) | 	suite.Equal(testAccount.NoteRaw, apimodelAccount.Source.Note) | ||||||
|  |  | ||||||
|  | @ -99,8 +99,8 @@ func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() { | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 1, |       "followers_count": 1, | ||||||
|       "following_count": 1, |       "following_count": 1, | ||||||
|       "statuses_count": 8, |       "statuses_count": 9, | ||||||
|       "last_status_at": "2021-07-28", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [ |       "fields": [ | ||||||
|         { |         { | ||||||
|  | @ -262,8 +262,8 @@ func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() { | ||||||
|       "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |       "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|       "followers_count": 2, |       "followers_count": 2, | ||||||
|       "following_count": 2, |       "following_count": 2, | ||||||
|       "statuses_count": 8, |       "statuses_count": 9, | ||||||
|       "last_status_at": "2024-01-10", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [], |       "fields": [], | ||||||
|       "enable_rss": true |       "enable_rss": true | ||||||
|  | @ -403,8 +403,8 @@ func (suite *AccountsGetTestSuite) TestAccountsGetFromTop() { | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 0, |       "followers_count": 0, | ||||||
|       "following_count": 0, |       "following_count": 0, | ||||||
|       "statuses_count": 3, |       "statuses_count": 4, | ||||||
|       "last_status_at": "2021-09-11", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [] |       "fields": [] | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -186,8 +186,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() { | ||||||
|         "header_description": "Flat gray background (default header).", |         "header_description": "Flat gray background (default header).", | ||||||
|         "followers_count": 0, |         "followers_count": 0, | ||||||
|         "following_count": 0, |         "following_count": 0, | ||||||
|         "statuses_count": 3, |         "statuses_count": 4, | ||||||
|         "last_status_at": "2021-09-11", |         "last_status_at": "2024-11-01", | ||||||
|         "emojis": [], |         "emojis": [], | ||||||
|         "fields": [] |         "fields": [] | ||||||
|       } |       } | ||||||
|  | @ -232,8 +232,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() { | ||||||
|         "header_description": "Flat gray background (default header).", |         "header_description": "Flat gray background (default header).", | ||||||
|         "followers_count": 1, |         "followers_count": 1, | ||||||
|         "following_count": 1, |         "following_count": 1, | ||||||
|         "statuses_count": 8, |         "statuses_count": 9, | ||||||
|         "last_status_at": "2021-07-28", |         "last_status_at": "2024-11-01", | ||||||
|         "emojis": [], |         "emojis": [], | ||||||
|         "fields": [ |         "fields": [ | ||||||
|           { |           { | ||||||
|  | @ -414,8 +414,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() { | ||||||
|         "header_description": "Flat gray background (default header).", |         "header_description": "Flat gray background (default header).", | ||||||
|         "followers_count": 1, |         "followers_count": 1, | ||||||
|         "following_count": 1, |         "following_count": 1, | ||||||
|         "statuses_count": 8, |         "statuses_count": 9, | ||||||
|         "last_status_at": "2021-07-28", |         "last_status_at": "2024-11-01", | ||||||
|         "emojis": [], |         "emojis": [], | ||||||
|         "fields": [ |         "fields": [ | ||||||
|           { |           { | ||||||
|  | @ -473,8 +473,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() { | ||||||
|         "header_description": "Flat gray background (default header).", |         "header_description": "Flat gray background (default header).", | ||||||
|         "followers_count": 0, |         "followers_count": 0, | ||||||
|         "following_count": 0, |         "following_count": 0, | ||||||
|         "statuses_count": 3, |         "statuses_count": 4, | ||||||
|         "last_status_at": "2021-09-11", |         "last_status_at": "2024-11-01", | ||||||
|         "emojis": [], |         "emojis": [], | ||||||
|         "fields": [] |         "fields": [] | ||||||
|       } |       } | ||||||
|  | @ -485,6 +485,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() { | ||||||
|       { |       { | ||||||
|         "id": "01FVW7JHQFSFK166WWKR8CBA6M", |         "id": "01FVW7JHQFSFK166WWKR8CBA6M", | ||||||
|         "created_at": "2021-09-20T10:40:37.000Z", |         "created_at": "2021-09-20T10:40:37.000Z", | ||||||
|  |         "edited_at": null, | ||||||
|         "in_reply_to_id": null, |         "in_reply_to_id": null, | ||||||
|         "in_reply_to_account_id": null, |         "in_reply_to_account_id": null, | ||||||
|         "sensitive": false, |         "sensitive": false, | ||||||
|  | @ -521,8 +522,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetAll() { | ||||||
|           "header_description": "Flat gray background (default header).", |           "header_description": "Flat gray background (default header).", | ||||||
|           "followers_count": 0, |           "followers_count": 0, | ||||||
|           "following_count": 0, |           "following_count": 0, | ||||||
|           "statuses_count": 3, |           "statuses_count": 4, | ||||||
|           "last_status_at": "2021-09-11", |           "last_status_at": "2024-11-01", | ||||||
|           "emojis": [], |           "emojis": [], | ||||||
|           "fields": [] |           "fields": [] | ||||||
|         }, |         }, | ||||||
|  | @ -667,8 +668,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() { | ||||||
|         "header_description": "Flat gray background (default header).", |         "header_description": "Flat gray background (default header).", | ||||||
|         "followers_count": 1, |         "followers_count": 1, | ||||||
|         "following_count": 1, |         "following_count": 1, | ||||||
|         "statuses_count": 8, |         "statuses_count": 9, | ||||||
|         "last_status_at": "2021-07-28", |         "last_status_at": "2024-11-01", | ||||||
|         "emojis": [], |         "emojis": [], | ||||||
|         "fields": [ |         "fields": [ | ||||||
|           { |           { | ||||||
|  | @ -726,8 +727,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() { | ||||||
|         "header_description": "Flat gray background (default header).", |         "header_description": "Flat gray background (default header).", | ||||||
|         "followers_count": 0, |         "followers_count": 0, | ||||||
|         "following_count": 0, |         "following_count": 0, | ||||||
|         "statuses_count": 3, |         "statuses_count": 4, | ||||||
|         "last_status_at": "2021-09-11", |         "last_status_at": "2024-11-01", | ||||||
|         "emojis": [], |         "emojis": [], | ||||||
|         "fields": [] |         "fields": [] | ||||||
|       } |       } | ||||||
|  | @ -738,6 +739,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() { | ||||||
|       { |       { | ||||||
|         "id": "01FVW7JHQFSFK166WWKR8CBA6M", |         "id": "01FVW7JHQFSFK166WWKR8CBA6M", | ||||||
|         "created_at": "2021-09-20T10:40:37.000Z", |         "created_at": "2021-09-20T10:40:37.000Z", | ||||||
|  |         "edited_at": null, | ||||||
|         "in_reply_to_id": null, |         "in_reply_to_id": null, | ||||||
|         "in_reply_to_account_id": null, |         "in_reply_to_account_id": null, | ||||||
|         "sensitive": false, |         "sensitive": false, | ||||||
|  | @ -774,8 +776,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetCreatedByAccount() { | ||||||
|           "header_description": "Flat gray background (default header).", |           "header_description": "Flat gray background (default header).", | ||||||
|           "followers_count": 0, |           "followers_count": 0, | ||||||
|           "following_count": 0, |           "following_count": 0, | ||||||
|           "statuses_count": 3, |           "statuses_count": 4, | ||||||
|           "last_status_at": "2021-09-11", |           "last_status_at": "2024-11-01", | ||||||
|           "emojis": [], |           "emojis": [], | ||||||
|           "fields": [] |           "fields": [] | ||||||
|         }, |         }, | ||||||
|  | @ -920,8 +922,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() { | ||||||
|         "header_description": "Flat gray background (default header).", |         "header_description": "Flat gray background (default header).", | ||||||
|         "followers_count": 1, |         "followers_count": 1, | ||||||
|         "following_count": 1, |         "following_count": 1, | ||||||
|         "statuses_count": 8, |         "statuses_count": 9, | ||||||
|         "last_status_at": "2021-07-28", |         "last_status_at": "2024-11-01", | ||||||
|         "emojis": [], |         "emojis": [], | ||||||
|         "fields": [ |         "fields": [ | ||||||
|           { |           { | ||||||
|  | @ -979,8 +981,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() { | ||||||
|         "header_description": "Flat gray background (default header).", |         "header_description": "Flat gray background (default header).", | ||||||
|         "followers_count": 0, |         "followers_count": 0, | ||||||
|         "following_count": 0, |         "following_count": 0, | ||||||
|         "statuses_count": 3, |         "statuses_count": 4, | ||||||
|         "last_status_at": "2021-09-11", |         "last_status_at": "2024-11-01", | ||||||
|         "emojis": [], |         "emojis": [], | ||||||
|         "fields": [] |         "fields": [] | ||||||
|       } |       } | ||||||
|  | @ -991,6 +993,7 @@ func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() { | ||||||
|       { |       { | ||||||
|         "id": "01FVW7JHQFSFK166WWKR8CBA6M", |         "id": "01FVW7JHQFSFK166WWKR8CBA6M", | ||||||
|         "created_at": "2021-09-20T10:40:37.000Z", |         "created_at": "2021-09-20T10:40:37.000Z", | ||||||
|  |         "edited_at": null, | ||||||
|         "in_reply_to_id": null, |         "in_reply_to_id": null, | ||||||
|         "in_reply_to_account_id": null, |         "in_reply_to_account_id": null, | ||||||
|         "sensitive": false, |         "sensitive": false, | ||||||
|  | @ -1027,8 +1030,8 @@ func (suite *ReportsGetTestSuite) TestReportsGetTargetAccount() { | ||||||
|           "header_description": "Flat gray background (default header).", |           "header_description": "Flat gray background (default header).", | ||||||
|           "followers_count": 0, |           "followers_count": 0, | ||||||
|           "following_count": 0, |           "following_count": 0, | ||||||
|           "statuses_count": 3, |           "statuses_count": 4, | ||||||
|           "last_status_at": "2021-09-11", |           "last_status_at": "2024-11-01", | ||||||
|           "emojis": [], |           "emojis": [], | ||||||
|           "fields": [] |           "fields": [] | ||||||
|         }, |         }, | ||||||
|  |  | ||||||
|  | @ -229,7 +229,7 @@ Cool Ass Posters From This Instance,admin@localhost:8080 | ||||||
|   "media_storage": "", |   "media_storage": "", | ||||||
|   "followers_count": 2, |   "followers_count": 2, | ||||||
|   "following_count": 2, |   "following_count": 2, | ||||||
|   "statuses_count": 8, |   "statuses_count": 9, | ||||||
|   "lists_count": 1, |   "lists_count": 1, | ||||||
|   "blocks_count": 0, |   "blocks_count": 0, | ||||||
|   "mutes_count": 0 |   "mutes_count": 0 | ||||||
|  |  | ||||||
|  | @ -155,7 +155,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch1() { | ||||||
|   }, |   }, | ||||||
|   "stats": { |   "stats": { | ||||||
|     "domain_count": 2, |     "domain_count": 2, | ||||||
|     "status_count": 19, |     "status_count": 21, | ||||||
|     "user_count": 4 |     "user_count": 4 | ||||||
|   }, |   }, | ||||||
|   "thumbnail": "http://localhost:8080/assets/logo.webp", |   "thumbnail": "http://localhost:8080/assets/logo.webp", | ||||||
|  | @ -296,7 +296,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch2() { | ||||||
|   }, |   }, | ||||||
|   "stats": { |   "stats": { | ||||||
|     "domain_count": 2, |     "domain_count": 2, | ||||||
|     "status_count": 19, |     "status_count": 21, | ||||||
|     "user_count": 4 |     "user_count": 4 | ||||||
|   }, |   }, | ||||||
|   "thumbnail": "http://localhost:8080/assets/logo.webp", |   "thumbnail": "http://localhost:8080/assets/logo.webp", | ||||||
|  | @ -437,7 +437,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch3() { | ||||||
|   }, |   }, | ||||||
|   "stats": { |   "stats": { | ||||||
|     "domain_count": 2, |     "domain_count": 2, | ||||||
|     "status_count": 19, |     "status_count": 21, | ||||||
|     "user_count": 4 |     "user_count": 4 | ||||||
|   }, |   }, | ||||||
|   "thumbnail": "http://localhost:8080/assets/logo.webp", |   "thumbnail": "http://localhost:8080/assets/logo.webp", | ||||||
|  | @ -629,7 +629,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch6() { | ||||||
|   }, |   }, | ||||||
|   "stats": { |   "stats": { | ||||||
|     "domain_count": 2, |     "domain_count": 2, | ||||||
|     "status_count": 19, |     "status_count": 21, | ||||||
|     "user_count": 4 |     "user_count": 4 | ||||||
|   }, |   }, | ||||||
|   "thumbnail": "http://localhost:8080/assets/logo.webp", |   "thumbnail": "http://localhost:8080/assets/logo.webp", | ||||||
|  | @ -792,7 +792,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch8() { | ||||||
|   }, |   }, | ||||||
|   "stats": { |   "stats": { | ||||||
|     "domain_count": 2, |     "domain_count": 2, | ||||||
|     "status_count": 19, |     "status_count": 21, | ||||||
|     "user_count": 4 |     "user_count": 4 | ||||||
|   }, |   }, | ||||||
|   "thumbnail": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/attachment/original/`+instanceAccount.AvatarMediaAttachment.ID+`.gif",`+` |   "thumbnail": "http://localhost:8080/fileserver/01AY6P665V14JJR0AFVRT7311Y/attachment/original/`+instanceAccount.AvatarMediaAttachment.ID+`.gif",`+` | ||||||
|  | @ -974,7 +974,7 @@ func (suite *InstancePatchTestSuite) TestInstancePatch9() { | ||||||
|   }, |   }, | ||||||
|   "stats": { |   "stats": { | ||||||
|     "domain_count": 2, |     "domain_count": 2, | ||||||
|     "status_count": 19, |     "status_count": 21, | ||||||
|     "user_count": 4 |     "user_count": 4 | ||||||
|   }, |   }, | ||||||
|   "thumbnail": "http://localhost:8080/assets/logo.webp", |   "thumbnail": "http://localhost:8080/assets/logo.webp", | ||||||
|  |  | ||||||
|  | @ -148,7 +148,7 @@ func (suite *MutesTestSuite) TestIndefinitelyMutedAccountSerializesMuteExpiratio | ||||||
| 
 | 
 | ||||||
| 	// Fetch all muted accounts for the logged-in account. | 	// Fetch all muted accounts for the logged-in account. | ||||||
| 	// The expected body contains `"mute_expires_at":null`. | 	// The expected body contains `"mute_expires_at":null`. | ||||||
| 	_, err = suite.getMutedAccounts(http.StatusOK, `[{"id":"01F8MH5ZK5VRH73AKHQM6Y9VNX","username":"foss_satan","acct":"foss_satan@fossbros-anonymous.io","display_name":"big gerald","locked":false,"discoverable":true,"bot":false,"created_at":"2021-09-26T10:52:36.000Z","note":"i post about like, i dunno, stuff, or whatever!!!!","url":"http://fossbros-anonymous.io/@foss_satan","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.webp","header_static":"http://localhost:8080/assets/default_header.webp","header_description":"Flat gray background (default header).","followers_count":0,"following_count":0,"statuses_count":3,"last_status_at":"2021-09-11","emojis":[],"fields":[],"mute_expires_at":null}]`) | 	_, err = suite.getMutedAccounts(http.StatusOK, `[{"id":"01F8MH5ZK5VRH73AKHQM6Y9VNX","username":"foss_satan","acct":"foss_satan@fossbros-anonymous.io","display_name":"big gerald","locked":false,"discoverable":true,"bot":false,"created_at":"2021-09-26T10:52:36.000Z","note":"i post about like, i dunno, stuff, or whatever!!!!","url":"http://fossbros-anonymous.io/@foss_satan","avatar":"","avatar_static":"","header":"http://localhost:8080/assets/default_header.webp","header_static":"http://localhost:8080/assets/default_header.webp","header_description":"Flat gray background (default header).","followers_count":0,"following_count":0,"statuses_count":4,"last_status_at":"2024-11-01","emojis":[],"fields":[],"mute_expires_at":null}]`) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		suite.FailNow(err.Error()) | 		suite.FailNow(err.Error()) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -130,8 +130,8 @@ func (suite *ReportGetTestSuite) TestGetReport1() { | ||||||
|     "header_description": "Flat gray background (default header).", |     "header_description": "Flat gray background (default header).", | ||||||
|     "followers_count": 0, |     "followers_count": 0, | ||||||
|     "following_count": 0, |     "following_count": 0, | ||||||
|     "statuses_count": 3, |     "statuses_count": 4, | ||||||
|     "last_status_at": "2021-09-11", |     "last_status_at": "2024-11-01", | ||||||
|     "emojis": [], |     "emojis": [], | ||||||
|     "fields": [] |     "fields": [] | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -156,8 +156,8 @@ func (suite *ReportsGetTestSuite) TestGetReports() { | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 0, |       "followers_count": 0, | ||||||
|       "following_count": 0, |       "following_count": 0, | ||||||
|       "statuses_count": 3, |       "statuses_count": 4, | ||||||
|       "last_status_at": "2021-09-11", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [] |       "fields": [] | ||||||
|     } |     } | ||||||
|  | @ -247,8 +247,8 @@ func (suite *ReportsGetTestSuite) TestGetReports4() { | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 0, |       "followers_count": 0, | ||||||
|       "following_count": 0, |       "following_count": 0, | ||||||
|       "statuses_count": 3, |       "statuses_count": 4, | ||||||
|       "last_status_at": "2021-09-11", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [] |       "fields": [] | ||||||
|     } |     } | ||||||
|  | @ -322,8 +322,8 @@ func (suite *ReportsGetTestSuite) TestGetReports6() { | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 0, |       "followers_count": 0, | ||||||
|       "following_count": 0, |       "following_count": 0, | ||||||
|       "statuses_count": 3, |       "statuses_count": 4, | ||||||
|       "last_status_at": "2021-09-11", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [] |       "fields": [] | ||||||
|     } |     } | ||||||
|  | @ -381,8 +381,8 @@ func (suite *ReportsGetTestSuite) TestGetReports7() { | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 0, |       "followers_count": 0, | ||||||
|       "following_count": 0, |       "following_count": 0, | ||||||
|       "statuses_count": 3, |       "statuses_count": 4, | ||||||
|       "last_status_at": "2021-09-11", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [] |       "fields": [] | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -916,7 +916,7 @@ func (suite *SearchGetTestSuite) TestSearchAAny() { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	suite.Len(searchResult.Accounts, 5) | 	suite.Len(searchResult.Accounts, 5) | ||||||
| 	suite.Len(searchResult.Statuses, 7) | 	suite.Len(searchResult.Statuses, 8) | ||||||
| 	suite.Len(searchResult.Hashtags, 0) | 	suite.Len(searchResult.Hashtags, 0) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -959,7 +959,7 @@ func (suite *SearchGetTestSuite) TestSearchAAnyFollowingOnly() { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	suite.Len(searchResult.Accounts, 2) | 	suite.Len(searchResult.Accounts, 2) | ||||||
| 	suite.Len(searchResult.Statuses, 7) | 	suite.Len(searchResult.Statuses, 8) | ||||||
| 	suite.Len(searchResult.Hashtags, 0) | 	suite.Len(searchResult.Hashtags, 0) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -1002,7 +1002,7 @@ func (suite *SearchGetTestSuite) TestSearchAStatuses() { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	suite.Len(searchResult.Accounts, 0) | 	suite.Len(searchResult.Accounts, 0) | ||||||
| 	suite.Len(searchResult.Statuses, 7) | 	suite.Len(searchResult.Statuses, 8) | ||||||
| 	suite.Len(searchResult.Hashtags, 0) | 	suite.Len(searchResult.Hashtags, 0) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -100,6 +100,7 @@ func (suite *StatusBoostTestSuite) TestPostBoost() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "", |   "content": "", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": true, |   "favourited": true, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -145,6 +146,7 @@ func (suite *StatusBoostTestSuite) TestPostBoost() { | ||||||
|     "card": null, |     "card": null, | ||||||
|     "content": "hello world! #welcome ! first post on the instance :rainbow: !", |     "content": "hello world! #welcome ! first post on the instance :rainbow: !", | ||||||
|     "created_at": "right the hell just now babyee", |     "created_at": "right the hell just now babyee", | ||||||
|  |     "edited_at": null, | ||||||
|     "emojis": [ |     "emojis": [ | ||||||
|       { |       { | ||||||
|         "category": "reactions", |         "category": "reactions", | ||||||
|  | @ -280,6 +282,7 @@ func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "", |   "content": "", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -329,6 +332,7 @@ func (suite *StatusBoostTestSuite) TestPostBoostOwnFollowersOnly() { | ||||||
|     "card": null, |     "card": null, | ||||||
|     "content": "hi!", |     "content": "hi!", | ||||||
|     "created_at": "right the hell just now babyee", |     "created_at": "right the hell just now babyee", | ||||||
|  |     "edited_at": null, | ||||||
|     "emojis": [], |     "emojis": [], | ||||||
|     "favourited": false, |     "favourited": false, | ||||||
|     "favourites_count": 0, |     "favourites_count": 0, | ||||||
|  | @ -494,6 +498,7 @@ func (suite *StatusBoostTestSuite) TestPostBoostImplicitAccept() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "", |   "content": "", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -539,6 +544,7 @@ func (suite *StatusBoostTestSuite) TestPostBoostImplicitAccept() { | ||||||
|     "card": null, |     "card": null, | ||||||
|     "content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>", |     "content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>", | ||||||
|     "created_at": "right the hell just now babyee", |     "created_at": "right the hell just now babyee", | ||||||
|  |     "edited_at": null, | ||||||
|     "emojis": [], |     "emojis": [], | ||||||
|     "favourited": false, |     "favourited": false, | ||||||
|     "favourites_count": 0, |     "favourites_count": 0, | ||||||
|  |  | ||||||
|  | @ -102,6 +102,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatus() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>", |   "content": "<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -187,6 +188,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusIntPolicy() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>", |   "content": "<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -282,6 +284,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusIntPolicyJSON() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>", |   "content": "<p>this is a brand new status! <a href=\"http://localhost:8080/tags/helloworld\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>helloworld</span></a></p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -407,6 +410,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusMarkdown() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<h1>Title</h1><h2>Smaller title</h2><p>This is a post written in <a href=\"https://www.markdownguide.org/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">markdown</a></p>", |   "content": "<h1>Title</h1><h2>Smaller title</h2><p>This is a post written in <a href=\"https://www.markdownguide.org/\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">markdown</a></p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -490,6 +494,7 @@ func (suite *StatusCreateTestSuite) TestMentionUnknownAccount() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<p>hello <span class=\"h-card\"><a href=\"https://unknown-instance.com/@brand_new_person\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>brand_new_person</span></a></span></p>", |   "content": "<p>hello <span class=\"h-card\"><a href=\"https://unknown-instance.com/@brand_new_person\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>brand_new_person</span></a></span></p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -567,6 +572,7 @@ func (suite *StatusCreateTestSuite) TestPostStatusWithLinksAndTags() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<p><a href=\"http://localhost:8080/tags/test\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>test</span></a> alright, should be able to post <a href=\"http://localhost:8080/tags/links\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>links</span></a> with fragments in them now, let's see........<br><br><a href=\"https://docs.gotosocial.org/en/latest/user_guide/posts/#links\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://docs.gotosocial.org/en/latest/user_guide/posts/#links</a><br><br><a href=\"http://localhost:8080/tags/gotosocial\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>gotosocial</span></a><br><br>(tobi remember to pull the docker image challenge)</p>", |   "content": "<p><a href=\"http://localhost:8080/tags/test\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>test</span></a> alright, should be able to post <a href=\"http://localhost:8080/tags/links\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>links</span></a> with fragments in them now, let's see........<br><br><a href=\"https://docs.gotosocial.org/en/latest/user_guide/posts/#links\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">https://docs.gotosocial.org/en/latest/user_guide/posts/#links</a><br><br><a href=\"http://localhost:8080/tags/gotosocial\" class=\"mention hashtag\" rel=\"tag nofollow noreferrer noopener\" target=\"_blank\">#<span>gotosocial</span></a><br><br>(tobi remember to pull the docker image challenge)</p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -650,6 +656,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithEmoji() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<p>here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow:<br>here's an emoji that isn't in the db: :test_emoji:</p>", |   "content": "<p>here is a rainbow emoji a few times! :rainbow: :rainbow: :rainbow:<br>here's an emoji that isn't in the db: :test_emoji:</p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [ |   "emojis": [ | ||||||
|     { |     { | ||||||
|       "category": "reactions", |       "category": "reactions", | ||||||
|  | @ -747,6 +754,7 @@ func (suite *StatusCreateTestSuite) TestReplyToLocalStatus() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<p>hello <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> this reply should work!</p>", |   "content": "<p>hello <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span> this reply should work!</p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -829,6 +837,7 @@ func (suite *StatusCreateTestSuite) TestAttachNewMediaSuccess() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<p>here's an image attachment</p>", |   "content": "<p>here's an image attachment</p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -933,6 +942,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithNoncanonicalLanguageTag | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<p>English? what's English? i speak American</p>", |   "content": "<p>English? what's English? i speak American</p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -1007,6 +1017,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithPollForm() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<p>this is a status with a poll!</p>", |   "content": "<p>this is a status with a poll!</p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  | @ -1103,6 +1114,7 @@ func (suite *StatusCreateTestSuite) TestPostNewStatusWithPollJSON() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<p>this is a status with a poll!</p>", |   "content": "<p>this is a status with a poll!</p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": false, |   "favourited": false, | ||||||
|   "favourites_count": 0, |   "favourites_count": 0, | ||||||
|  |  | ||||||
|  | @ -105,6 +105,7 @@ func (suite *StatusFaveTestSuite) TestPostFave() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "🐕🐕🐕🐕🐕", |   "content": "🐕🐕🐕🐕🐕", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": true, |   "favourited": true, | ||||||
|   "favourites_count": 1, |   "favourites_count": 1, | ||||||
|  | @ -228,6 +229,7 @@ func (suite *StatusFaveTestSuite) TestPostFaveImplicitAccept() { | ||||||
|   "card": null, |   "card": null, | ||||||
|   "content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>", |   "content": "<p>Hi <span class=\"h-card\"><a href=\"http://localhost:8080/@1happyturtle\" class=\"u-url mention\" rel=\"nofollow noreferrer noopener\" target=\"_blank\">@<span>1happyturtle</span></a></span>, can I reply?</p>", | ||||||
|   "created_at": "right the hell just now babyee", |   "created_at": "right the hell just now babyee", | ||||||
|  |   "edited_at": null, | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "favourited": true, |   "favourited": true, | ||||||
|   "favourites_count": 1, |   "favourites_count": 1, | ||||||
|  |  | ||||||
|  | @ -116,8 +116,8 @@ func (suite *StatusHistoryTestSuite) TestGetHistory() { | ||||||
|       "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |       "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|       "followers_count": 2, |       "followers_count": 2, | ||||||
|       "following_count": 2, |       "following_count": 2, | ||||||
|       "statuses_count": 8, |       "statuses_count": 9, | ||||||
|       "last_status_at": "2024-01-10", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [], |       "fields": [], | ||||||
|       "enable_rss": true |       "enable_rss": true | ||||||
|  |  | ||||||
|  | @ -91,6 +91,7 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() { | ||||||
| 	suite.Equal(`{ | 	suite.Equal(`{ | ||||||
|   "id": "01F8MHAMCHF6Y650WCRSCP4WMY", |   "id": "01F8MHAMCHF6Y650WCRSCP4WMY", | ||||||
|   "created_at": "2021-10-20T10:40:37.000Z", |   "created_at": "2021-10-20T10:40:37.000Z", | ||||||
|  |   "edited_at": null, | ||||||
|   "in_reply_to_id": null, |   "in_reply_to_id": null, | ||||||
|   "in_reply_to_account_id": null, |   "in_reply_to_account_id": null, | ||||||
|   "sensitive": true, |   "sensitive": true, | ||||||
|  | @ -134,8 +135,8 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() { | ||||||
|     "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |     "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|     "followers_count": 2, |     "followers_count": 2, | ||||||
|     "following_count": 2, |     "following_count": 2, | ||||||
|     "statuses_count": 8, |     "statuses_count": 9, | ||||||
|     "last_status_at": "2024-01-10", |     "last_status_at": "2024-11-01", | ||||||
|     "emojis": [], |     "emojis": [], | ||||||
|     "fields": [], |     "fields": [], | ||||||
|     "enable_rss": true |     "enable_rss": true | ||||||
|  | @ -178,6 +179,7 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() { | ||||||
| 	suite.Equal(`{ | 	suite.Equal(`{ | ||||||
|   "id": "01F8MHAMCHF6Y650WCRSCP4WMY", |   "id": "01F8MHAMCHF6Y650WCRSCP4WMY", | ||||||
|   "created_at": "2021-10-20T10:40:37.000Z", |   "created_at": "2021-10-20T10:40:37.000Z", | ||||||
|  |   "edited_at": null, | ||||||
|   "in_reply_to_id": null, |   "in_reply_to_id": null, | ||||||
|   "in_reply_to_account_id": null, |   "in_reply_to_account_id": null, | ||||||
|   "sensitive": true, |   "sensitive": true, | ||||||
|  | @ -221,8 +223,8 @@ func (suite *StatusMuteTestSuite) TestMuteUnmuteStatus() { | ||||||
|     "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |     "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|     "followers_count": 2, |     "followers_count": 2, | ||||||
|     "following_count": 2, |     "following_count": 2, | ||||||
|     "statuses_count": 8, |     "statuses_count": 9, | ||||||
|     "last_status_at": "2024-01-10", |     "last_status_at": "2024-11-01", | ||||||
|     "emojis": [], |     "emojis": [], | ||||||
|     "fields": [], |     "fields": [], | ||||||
|     "enable_rss": true |     "enable_rss": true | ||||||
|  |  | ||||||
|  | @ -19,7 +19,6 @@ package model | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"io" | 	"io" | ||||||
| 	"time" |  | ||||||
| 
 | 
 | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/storage" | 	"github.com/superseriousbusiness/gotosocial/internal/storage" | ||||||
| ) | ) | ||||||
|  | @ -30,8 +29,6 @@ type Content struct { | ||||||
| 	ContentType string | 	ContentType string | ||||||
| 	// ContentLength in bytes | 	// ContentLength in bytes | ||||||
| 	ContentLength int64 | 	ContentLength int64 | ||||||
| 	// Time when the content was last updated. |  | ||||||
| 	ContentUpdated time.Time |  | ||||||
| 	// Actual content | 	// Actual content | ||||||
| 	Content io.ReadCloser | 	Content io.ReadCloser | ||||||
| 	// Resource URL to forward to if the file can be fetched from the storage directly (e.g signed S3 URL) | 	// Resource URL to forward to if the file can be fetched from the storage directly (e.g signed S3 URL) | ||||||
|  |  | ||||||
|  | @ -29,6 +29,10 @@ type Status struct { | ||||||
| 	// The date when this status was created (ISO 8601 Datetime). | 	// The date when this status was created (ISO 8601 Datetime). | ||||||
| 	// example: 2021-07-30T09:20:25+00:00 | 	// example: 2021-07-30T09:20:25+00:00 | ||||||
| 	CreatedAt string `json:"created_at"` | 	CreatedAt string `json:"created_at"` | ||||||
|  | 	// Timestamp of when the status was last edited (ISO 8601 Datetime). | ||||||
|  | 	// example: 2021-07-30T09:20:25+00:00 | ||||||
|  | 	// nullable: true | ||||||
|  | 	EditedAt *string `json:"edited_at"` | ||||||
| 	// ID of the status being replied to. | 	// ID of the status being replied to. | ||||||
| 	// example: 01FBVD42CQ3ZEEVMW180SBX03B | 	// example: 01FBVD42CQ3ZEEVMW180SBX03B | ||||||
| 	// nullable: true | 	// nullable: true | ||||||
|  |  | ||||||
							
								
								
									
										1
									
								
								internal/cache/cache.go
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								internal/cache/cache.go
									
										
									
									
										vendored
									
									
								
							|  | @ -105,6 +105,7 @@ func (c *Caches) Init() { | ||||||
| 	c.initStatus() | 	c.initStatus() | ||||||
| 	c.initStatusBookmark() | 	c.initStatusBookmark() | ||||||
| 	c.initStatusBookmarkIDs() | 	c.initStatusBookmarkIDs() | ||||||
|  | 	c.initStatusEdit() | ||||||
| 	c.initStatusFave() | 	c.initStatusFave() | ||||||
| 	c.initStatusFaveIDs() | 	c.initStatusFaveIDs() | ||||||
| 	c.initTag() | 	c.initTag() | ||||||
|  |  | ||||||
							
								
								
									
										35
									
								
								internal/cache/db.go
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										35
									
								
								internal/cache/db.go
									
										
									
									
										vendored
									
									
								
							|  | @ -226,6 +226,9 @@ type DBCaches struct { | ||||||
| 	// StatusBookmarkIDs provides access to the status bookmark IDs list database cache. | 	// StatusBookmarkIDs provides access to the status bookmark IDs list database cache. | ||||||
| 	StatusBookmarkIDs SliceCache[string] | 	StatusBookmarkIDs SliceCache[string] | ||||||
| 
 | 
 | ||||||
|  | 	// StatusEdit provides access to the gtsmodel StatusEdit database cache. | ||||||
|  | 	StatusEdit StructCache[*gtsmodel.StatusEdit] | ||||||
|  | 
 | ||||||
| 	// StatusFave provides access to the gtsmodel StatusFave database cache. | 	// StatusFave provides access to the gtsmodel StatusFave database cache. | ||||||
| 	StatusFave StructCache[*gtsmodel.StatusFave] | 	StatusFave StructCache[*gtsmodel.StatusFave] | ||||||
| 
 | 
 | ||||||
|  | @ -1385,6 +1388,38 @@ func (c *Caches) initStatusBookmarkIDs() { | ||||||
| 	c.DB.StatusBookmarkIDs.Init(0, cap) | 	c.DB.StatusBookmarkIDs.Init(0, cap) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (c *Caches) initStatusEdit() { | ||||||
|  | 	// Calculate maximum cache size. | ||||||
|  | 	cap := calculateResultCacheMax( | ||||||
|  | 		sizeofStatusEdit(), // model in-mem size. | ||||||
|  | 		config.GetCacheStatusEditMemRatio(), | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	log.Infof(nil, "cache size = %d", cap) | ||||||
|  | 
 | ||||||
|  | 	copyF := func(s1 *gtsmodel.StatusEdit) *gtsmodel.StatusEdit { | ||||||
|  | 		s2 := new(gtsmodel.StatusEdit) | ||||||
|  | 		*s2 = *s1 | ||||||
|  | 
 | ||||||
|  | 		// Don't include ptr fields that | ||||||
|  | 		// will be populated separately. | ||||||
|  | 		s2.Attachments = nil | ||||||
|  | 
 | ||||||
|  | 		return s2 | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	c.DB.StatusEdit.Init(structr.CacheConfig[*gtsmodel.StatusEdit]{ | ||||||
|  | 		Indices: []structr.IndexConfig{ | ||||||
|  | 			{Fields: "ID"}, | ||||||
|  | 			{Fields: "StatusID", Multiple: true}, | ||||||
|  | 		}, | ||||||
|  | 		MaxSize:    cap, | ||||||
|  | 		IgnoreErr:  ignoreErrors, | ||||||
|  | 		Copy:       copyF, | ||||||
|  | 		Invalidate: c.OnInvalidateStatusEdit, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (c *Caches) initStatusFave() { | func (c *Caches) initStatusFave() { | ||||||
| 	// Calculate maximum cache size. | 	// Calculate maximum cache size. | ||||||
| 	cap := calculateResultCacheMax( | 	cap := calculateResultCacheMax( | ||||||
|  |  | ||||||
							
								
								
									
										5
									
								
								internal/cache/invalidate.go
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										5
									
								
								internal/cache/invalidate.go
									
										
									
									
										vendored
									
									
								
							|  | @ -273,6 +273,11 @@ func (c *Caches) OnInvalidateStatusBookmark(bookmark *gtsmodel.StatusBookmark) { | ||||||
| 	c.DB.StatusBookmarkIDs.Invalidate(bookmark.StatusID) | 	c.DB.StatusBookmarkIDs.Invalidate(bookmark.StatusID) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (c *Caches) OnInvalidateStatusEdit(edit *gtsmodel.StatusEdit) { | ||||||
|  | 	// Invalidate cache of related status model. | ||||||
|  | 	c.DB.Status.Invalidate("ID", edit.StatusID) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func (c *Caches) OnInvalidateStatusFave(fave *gtsmodel.StatusFave) { | func (c *Caches) OnInvalidateStatusFave(fave *gtsmodel.StatusFave) { | ||||||
| 	// Invalidate status fave ID list for this status. | 	// Invalidate status fave ID list for this status. | ||||||
| 	c.DB.StatusFaveIDs.Invalidate(fave.StatusID) | 	c.DB.StatusFaveIDs.Invalidate(fave.StatusID) | ||||||
|  |  | ||||||
							
								
								
									
										19
									
								
								internal/cache/size.go
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										19
									
								
								internal/cache/size.go
									
										
									
									
										vendored
									
									
								
							|  | @ -505,7 +505,6 @@ func sizeofMedia() uintptr { | ||||||
| 		URL:               exampleURI, | 		URL:               exampleURI, | ||||||
| 		RemoteURL:         exampleURI, | 		RemoteURL:         exampleURI, | ||||||
| 		CreatedAt:         exampleTime, | 		CreatedAt:         exampleTime, | ||||||
| 		UpdatedAt:         exampleTime, |  | ||||||
| 		Type:              gtsmodel.FileTypeImage, | 		Type:              gtsmodel.FileTypeImage, | ||||||
| 		AccountID:         exampleID, | 		AccountID:         exampleID, | ||||||
| 		Description:       exampleText, | 		Description:       exampleText, | ||||||
|  | @ -532,7 +531,6 @@ func sizeofMention() uintptr { | ||||||
| 		ID:               exampleURI, | 		ID:               exampleURI, | ||||||
| 		StatusID:         exampleURI, | 		StatusID:         exampleURI, | ||||||
| 		CreatedAt:        exampleTime, | 		CreatedAt:        exampleTime, | ||||||
| 		UpdatedAt:        exampleTime, |  | ||||||
| 		OriginAccountID:  exampleURI, | 		OriginAccountID:  exampleURI, | ||||||
| 		OriginAccountURI: exampleURI, | 		OriginAccountURI: exampleURI, | ||||||
| 		TargetAccountID:  exampleID, | 		TargetAccountID:  exampleID, | ||||||
|  | @ -674,6 +672,23 @@ func sizeofStatusBookmark() uintptr { | ||||||
| 	})) | 	})) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func sizeofStatusEdit() uintptr { | ||||||
|  | 	return uintptr(size.Of(>smodel.StatusEdit{ | ||||||
|  | 		ID:             exampleID, | ||||||
|  | 		Content:        exampleText, | ||||||
|  | 		ContentWarning: exampleUsername, // similar length | ||||||
|  | 		Text:           exampleText, | ||||||
|  | 		Language:       "en", | ||||||
|  | 		Sensitive:      func() *bool { ok := false; return &ok }(), | ||||||
|  | 		AttachmentIDs:  []string{exampleID, exampleID, exampleID}, | ||||||
|  | 		Attachments:    nil, | ||||||
|  | 		PollOptions:    []string{exampleTextSmall, exampleTextSmall, exampleTextSmall, exampleTextSmall}, | ||||||
|  | 		PollVotes:      []int{69, 420, 1337, 1969}, | ||||||
|  | 		StatusID:       exampleID, | ||||||
|  | 		CreatedAt:      exampleTime, | ||||||
|  | 	})) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func sizeofStatusFave() uintptr { | func sizeofStatusFave() uintptr { | ||||||
| 	return uintptr(size.Of(>smodel.StatusFave{ | 	return uintptr(size.Of(>smodel.StatusFave{ | ||||||
| 		ID:              exampleID, | 		ID:              exampleID, | ||||||
|  |  | ||||||
|  | @ -238,6 +238,7 @@ type CacheConfiguration struct { | ||||||
| 	StatusMemRatio                    float64       `name:"status-mem-ratio"` | 	StatusMemRatio                    float64       `name:"status-mem-ratio"` | ||||||
| 	StatusBookmarkMemRatio            float64       `name:"status-bookmark-mem-ratio"` | 	StatusBookmarkMemRatio            float64       `name:"status-bookmark-mem-ratio"` | ||||||
| 	StatusBookmarkIDsMemRatio         float64       `name:"status-bookmark-ids-mem-ratio"` | 	StatusBookmarkIDsMemRatio         float64       `name:"status-bookmark-ids-mem-ratio"` | ||||||
|  | 	StatusEditMemRatio                float64       `name:"status-edit-mem-ratio"` | ||||||
| 	StatusFaveMemRatio                float64       `name:"status-fave-mem-ratio"` | 	StatusFaveMemRatio                float64       `name:"status-fave-mem-ratio"` | ||||||
| 	StatusFaveIDsMemRatio             float64       `name:"status-fave-ids-mem-ratio"` | 	StatusFaveIDsMemRatio             float64       `name:"status-fave-ids-mem-ratio"` | ||||||
| 	TagMemRatio                       float64       `name:"tag-mem-ratio"` | 	TagMemRatio                       float64       `name:"tag-mem-ratio"` | ||||||
|  |  | ||||||
|  | @ -199,6 +199,7 @@ var Defaults = Configuration{ | ||||||
| 		StatusMemRatio:                    5, | 		StatusMemRatio:                    5, | ||||||
| 		StatusBookmarkMemRatio:            0.5, | 		StatusBookmarkMemRatio:            0.5, | ||||||
| 		StatusBookmarkIDsMemRatio:         2, | 		StatusBookmarkIDsMemRatio:         2, | ||||||
|  | 		StatusEditMemRatio:                2, | ||||||
| 		StatusFaveMemRatio:                2, | 		StatusFaveMemRatio:                2, | ||||||
| 		StatusFaveIDsMemRatio:             3, | 		StatusFaveIDsMemRatio:             3, | ||||||
| 		TagMemRatio:                       2, | 		TagMemRatio:                       2, | ||||||
|  |  | ||||||
|  | @ -3912,6 +3912,31 @@ func GetCacheStatusBookmarkIDsMemRatio() float64 { return global.GetCacheStatusB | ||||||
| // SetCacheStatusBookmarkIDsMemRatio safely sets the value for global configuration 'Cache.StatusBookmarkIDsMemRatio' field | // SetCacheStatusBookmarkIDsMemRatio safely sets the value for global configuration 'Cache.StatusBookmarkIDsMemRatio' field | ||||||
| func SetCacheStatusBookmarkIDsMemRatio(v float64) { global.SetCacheStatusBookmarkIDsMemRatio(v) } | func SetCacheStatusBookmarkIDsMemRatio(v float64) { global.SetCacheStatusBookmarkIDsMemRatio(v) } | ||||||
| 
 | 
 | ||||||
|  | // GetCacheStatusEditMemRatio safely fetches the Configuration value for state's 'Cache.StatusEditMemRatio' field | ||||||
|  | func (st *ConfigState) GetCacheStatusEditMemRatio() (v float64) { | ||||||
|  | 	st.mutex.RLock() | ||||||
|  | 	v = st.config.Cache.StatusEditMemRatio | ||||||
|  | 	st.mutex.RUnlock() | ||||||
|  | 	return | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SetCacheStatusEditMemRatio safely sets the Configuration value for state's 'Cache.StatusEditMemRatio' field | ||||||
|  | func (st *ConfigState) SetCacheStatusEditMemRatio(v float64) { | ||||||
|  | 	st.mutex.Lock() | ||||||
|  | 	defer st.mutex.Unlock() | ||||||
|  | 	st.config.Cache.StatusEditMemRatio = v | ||||||
|  | 	st.reloadToViper() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // CacheStatusEditMemRatioFlag returns the flag name for the 'Cache.StatusEditMemRatio' field | ||||||
|  | func CacheStatusEditMemRatioFlag() string { return "cache-status-edit-mem-ratio" } | ||||||
|  | 
 | ||||||
|  | // GetCacheStatusEditMemRatio safely fetches the value for global configuration 'Cache.StatusEditMemRatio' field | ||||||
|  | func GetCacheStatusEditMemRatio() float64 { return global.GetCacheStatusEditMemRatio() } | ||||||
|  | 
 | ||||||
|  | // SetCacheStatusEditMemRatio safely sets the value for global configuration 'Cache.StatusEditMemRatio' field | ||||||
|  | func SetCacheStatusEditMemRatio(v float64) { global.SetCacheStatusEditMemRatio(v) } | ||||||
|  | 
 | ||||||
| // GetCacheStatusFaveMemRatio safely fetches the Configuration value for state's 'Cache.StatusFaveMemRatio' field | // GetCacheStatusFaveMemRatio safely fetches the Configuration value for state's 'Cache.StatusFaveMemRatio' field | ||||||
| func (st *ConfigState) GetCacheStatusFaveMemRatio() (v float64) { | func (st *ConfigState) GetCacheStatusFaveMemRatio() (v float64) { | ||||||
| 	st.mutex.RLock() | 	st.mutex.RLock() | ||||||
|  |  | ||||||
|  | @ -46,7 +46,7 @@ type AccountTestSuite struct { | ||||||
| func (suite *AccountTestSuite) TestGetAccountStatuses() { | func (suite *AccountTestSuite) TestGetAccountStatuses() { | ||||||
| 	statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, false, false, "", "", false, false) | 	statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, false, false, "", "", false, false) | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.Len(statuses, 8) | 	suite.Len(statuses, 9) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() { | func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() { | ||||||
|  | @ -69,7 +69,7 @@ func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() { | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		suite.FailNow(err.Error()) | 		suite.FailNow(err.Error()) | ||||||
| 	} | 	} | ||||||
| 	suite.Len(statuses, 2) | 	suite.Len(statuses, 3) | ||||||
| 
 | 
 | ||||||
| 	// try to get the last page (should be empty) | 	// try to get the last page (should be empty) | ||||||
| 	statuses, err = suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 3, false, false, statuses[len(statuses)-1].ID, "", false, false) | 	statuses, err = suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 3, false, false, statuses[len(statuses)-1].ID, "", false, false) | ||||||
|  | @ -80,13 +80,13 @@ func (suite *AccountTestSuite) TestGetAccountStatusesPageDown() { | ||||||
| func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesAndReblogs() { | func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesAndReblogs() { | ||||||
| 	statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, true, true, "", "", false, false) | 	statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, true, true, "", "", false, false) | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.Len(statuses, 7) | 	suite.Len(statuses, 8) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesAndReblogsPublicOnly() { | func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesAndReblogsPublicOnly() { | ||||||
| 	statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, true, true, "", "", false, true) | 	statuses, err := suite.db.GetAccountStatuses(context.Background(), suite.testAccounts["local_account_1"].ID, 20, true, true, "", "", false, true) | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.Len(statuses, 3) | 	suite.Len(statuses, 4) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // populateTestStatus adds mandatory fields to a partially populated status. | // populateTestStatus adds mandatory fields to a partially populated status. | ||||||
|  | @ -173,7 +173,7 @@ func (suite *AccountTestSuite) TestGetAccountStatusesExcludeRepliesExcludesSelfR | ||||||
| 	testAccount := suite.testAccounts["local_account_1"] | 	testAccount := suite.testAccounts["local_account_1"] | ||||||
| 	statuses, err := suite.db.GetAccountStatuses(context.Background(), testAccount.ID, 20, true, true, "", "", false, false) | 	statuses, err := suite.db.GetAccountStatuses(context.Background(), testAccount.ID, 20, true, true, "", "", false, false) | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.Len(statuses, 8) | 	suite.Len(statuses, 9) | ||||||
| 	for _, status := range statuses { | 	for _, status := range statuses { | ||||||
| 		if status.InReplyToID != "" && status.InReplyToAccountID != testAccount.ID { | 		if status.InReplyToID != "" && status.InReplyToAccountID != testAccount.ID { | ||||||
| 			suite.FailNowf("", "Status with ID %s is a non-self reply and should have been excluded", status.ID) | 			suite.FailNowf("", "Status with ID %s is a non-self reply and should have been excluded", status.ID) | ||||||
|  |  | ||||||
|  | @ -114,7 +114,7 @@ func (suite *BasicTestSuite) TestGetAllStatuses() { | ||||||
| 	s := []*gtsmodel.Status{} | 	s := []*gtsmodel.Status{} | ||||||
| 	err := suite.db.GetAll(context.Background(), &s) | 	err := suite.db.GetAll(context.Background(), &s) | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.Len(s, 25) | 	suite.Len(s, 28) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *BasicTestSuite) TestGetAllNotNull() { | func (suite *BasicTestSuite) TestGetAllNotNull() { | ||||||
|  |  | ||||||
|  | @ -81,6 +81,7 @@ type DBService struct { | ||||||
| 	db.SinBinStatus | 	db.SinBinStatus | ||||||
| 	db.Status | 	db.Status | ||||||
| 	db.StatusBookmark | 	db.StatusBookmark | ||||||
|  | 	db.StatusEdit | ||||||
| 	db.StatusFave | 	db.StatusFave | ||||||
| 	db.Tag | 	db.Tag | ||||||
| 	db.Thread | 	db.Thread | ||||||
|  | @ -272,6 +273,10 @@ func NewBunDBService(ctx context.Context, state *state.State) (db.DB, error) { | ||||||
| 			db:    db, | 			db:    db, | ||||||
| 			state: state, | 			state: state, | ||||||
| 		}, | 		}, | ||||||
|  | 		StatusEdit: &statusEditDB{ | ||||||
|  | 			db:    db, | ||||||
|  | 			state: state, | ||||||
|  | 		}, | ||||||
| 		StatusFave: &statusFaveDB{ | 		StatusFave: &statusFaveDB{ | ||||||
| 			db:    db, | 			db:    db, | ||||||
| 			state: state, | 			state: state, | ||||||
|  |  | ||||||
|  | @ -57,6 +57,7 @@ type BunDBStandardTestSuite struct { | ||||||
| 	testPolls               map[string]*gtsmodel.Poll | 	testPolls               map[string]*gtsmodel.Poll | ||||||
| 	testPollVotes           map[string]*gtsmodel.PollVote | 	testPollVotes           map[string]*gtsmodel.PollVote | ||||||
| 	testInteractionRequests map[string]*gtsmodel.InteractionRequest | 	testInteractionRequests map[string]*gtsmodel.InteractionRequest | ||||||
|  | 	testStatusEdits         map[string]*gtsmodel.StatusEdit | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *BunDBStandardTestSuite) SetupSuite() { | func (suite *BunDBStandardTestSuite) SetupSuite() { | ||||||
|  | @ -83,6 +84,7 @@ func (suite *BunDBStandardTestSuite) SetupSuite() { | ||||||
| 	suite.testPolls = testrig.NewTestPolls() | 	suite.testPolls = testrig.NewTestPolls() | ||||||
| 	suite.testPollVotes = testrig.NewTestPollVotes() | 	suite.testPollVotes = testrig.NewTestPollVotes() | ||||||
| 	suite.testInteractionRequests = testrig.NewTestInteractionRequests() | 	suite.testInteractionRequests = testrig.NewTestInteractionRequests() | ||||||
|  | 	suite.testStatusEdits = testrig.NewTestStatusEdits() | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *BunDBStandardTestSuite) SetupTest() { | func (suite *BunDBStandardTestSuite) SetupTest() { | ||||||
|  |  | ||||||
|  | @ -47,13 +47,13 @@ func (suite *InstanceTestSuite) TestCountInstanceUsersRemote() { | ||||||
| func (suite *InstanceTestSuite) TestCountInstanceStatuses() { | func (suite *InstanceTestSuite) TestCountInstanceStatuses() { | ||||||
| 	count, err := suite.db.CountInstanceStatuses(context.Background(), config.GetHost()) | 	count, err := suite.db.CountInstanceStatuses(context.Background(), config.GetHost()) | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.Equal(19, count) | 	suite.Equal(21, count) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *InstanceTestSuite) TestCountInstanceStatusesRemote() { | func (suite *InstanceTestSuite) TestCountInstanceStatusesRemote() { | ||||||
| 	count, err := suite.db.CountInstanceStatuses(context.Background(), "fossbros-anonymous.io") | 	count, err := suite.db.CountInstanceStatuses(context.Background(), "fossbros-anonymous.io") | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.Equal(3, count) | 	suite.Equal(4, count) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *InstanceTestSuite) TestCountInstanceDomains() { | func (suite *InstanceTestSuite) TestCountInstanceDomains() { | ||||||
|  |  | ||||||
|  | @ -59,11 +59,7 @@ func (suite *InteractionTestSuite) markInteractionsPending( | ||||||
| 
 | 
 | ||||||
| 		// Put an interaction request | 		// Put an interaction request | ||||||
| 		// in the DB for this reply. | 		// in the DB for this reply. | ||||||
| 		req, err := typeutils.StatusToInteractionRequest(ctx, reply) | 		req := typeutils.StatusToInteractionRequest(reply) | ||||||
| 		if err != nil { |  | ||||||
| 			suite.FailNow(err.Error()) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil { | 		if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil { | ||||||
| 			suite.FailNow(err.Error()) | 			suite.FailNow(err.Error()) | ||||||
| 		} | 		} | ||||||
|  | @ -90,11 +86,7 @@ func (suite *InteractionTestSuite) markInteractionsPending( | ||||||
| 
 | 
 | ||||||
| 		// Put an interaction request | 		// Put an interaction request | ||||||
| 		// in the DB for this boost. | 		// in the DB for this boost. | ||||||
| 		req, err := typeutils.StatusToInteractionRequest(ctx, boost) | 		req := typeutils.StatusToInteractionRequest(boost) | ||||||
| 		if err != nil { |  | ||||||
| 			suite.FailNow(err.Error()) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil { | 		if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil { | ||||||
| 			suite.FailNow(err.Error()) | 			suite.FailNow(err.Error()) | ||||||
| 		} | 		} | ||||||
|  | @ -121,11 +113,7 @@ func (suite *InteractionTestSuite) markInteractionsPending( | ||||||
| 
 | 
 | ||||||
| 		// Put an interaction request | 		// Put an interaction request | ||||||
| 		// in the DB for this fave. | 		// in the DB for this fave. | ||||||
| 		req, err := typeutils.StatusFaveToInteractionRequest(ctx, fave) | 		req := typeutils.StatusFaveToInteractionRequest(fave) | ||||||
| 		if err != nil { |  | ||||||
| 			suite.FailNow(err.Error()) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil { | 		if err := suite.state.DB.PutInteractionRequest(ctx, req); err != nil { | ||||||
| 			suite.FailNow(err.Error()) | 			suite.FailNow(err.Error()) | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
|  | @ -104,12 +104,6 @@ func (m *mediaDB) PutAttachment(ctx context.Context, media *gtsmodel.MediaAttach | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (m *mediaDB) UpdateAttachment(ctx context.Context, media *gtsmodel.MediaAttachment, columns ...string) error { | func (m *mediaDB) UpdateAttachment(ctx context.Context, media *gtsmodel.MediaAttachment, columns ...string) error { | ||||||
| 	media.UpdatedAt = time.Now() |  | ||||||
| 	if len(columns) > 0 { |  | ||||||
| 		// If we're updating by column, ensure "updated_at" is included. |  | ||||||
| 		columns = append(columns, "updated_at") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return m.state.Caches.DB.Media.Store(media, func() error { | 	return m.state.Caches.DB.Media.Store(media, func() error { | ||||||
| 		_, err := m.db.NewUpdate(). | 		_, err := m.db.NewUpdate(). | ||||||
| 			Model(media). | 			Model(media). | ||||||
|  |  | ||||||
|  | @ -93,11 +93,7 @@ func init() { | ||||||
| 			// For each currently pending status, check whether it's a reply or | 			// For each currently pending status, check whether it's a reply or | ||||||
| 			// a boost, and insert a corresponding interaction request into the db. | 			// a boost, and insert a corresponding interaction request into the db. | ||||||
| 			for _, pendingStatus := range pendingStatuses { | 			for _, pendingStatus := range pendingStatuses { | ||||||
| 				req, err := typeutils.StatusToInteractionRequest(ctx, pendingStatus) | 				req := typeutils.StatusToInteractionRequest(pendingStatus) | ||||||
| 				if err != nil { |  | ||||||
| 					return err |  | ||||||
| 				} |  | ||||||
| 
 |  | ||||||
| 				if _, err := tx. | 				if _, err := tx. | ||||||
| 					NewInsert(). | 					NewInsert(). | ||||||
| 					Model(req). | 					Model(req). | ||||||
|  | @ -125,10 +121,7 @@ func init() { | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			for _, pendingFave := range pendingFaves { | 			for _, pendingFave := range pendingFaves { | ||||||
| 				req, err := typeutils.StatusFaveToInteractionRequest(ctx, pendingFave) | 				req := typeutils.StatusFaveToInteractionRequest(pendingFave) | ||||||
| 				if err != nil { |  | ||||||
| 					return err |  | ||||||
| 				} |  | ||||||
| 
 | 
 | ||||||
| 				if _, err := tx. | 				if _, err := tx. | ||||||
| 					NewInsert(). | 					NewInsert(). | ||||||
|  |  | ||||||
|  | @ -0,0 +1,57 @@ | ||||||
|  | // 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" | ||||||
|  | 
 | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/uptrace/bun" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func init() { | ||||||
|  | 	up := func(ctx context.Context, db *bun.DB) error { | ||||||
|  | 		return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { | ||||||
|  | 
 | ||||||
|  | 			// Check for 'updated_at' column on mentions table, else return. | ||||||
|  | 			exists, err := doesColumnExist(ctx, tx, "mentions", "updated_at") | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} else if !exists { | ||||||
|  | 				return nil | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			// Remove 'updated_at' column. | ||||||
|  | 			_, err = tx.NewDropColumn(). | ||||||
|  | 				Model((*gtsmodel.Mention)(nil)). | ||||||
|  | 				Column("updated_at"). | ||||||
|  | 				Exec(ctx) | ||||||
|  | 			return err | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	down := func(ctx context.Context, db *bun.DB) error { | ||||||
|  | 		return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { | ||||||
|  | 			return nil | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := Migrations.Register(up, down); err != nil { | ||||||
|  | 		panic(err) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | @ -0,0 +1,67 @@ | ||||||
|  | // 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" | ||||||
|  | 	"reflect" | ||||||
|  | 
 | ||||||
|  | 	gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241113152126_add_status_edits" | ||||||
|  | 
 | ||||||
|  | 	"github.com/uptrace/bun" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func init() { | ||||||
|  | 	up := func(ctx context.Context, db *bun.DB) error { | ||||||
|  | 		return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { | ||||||
|  | 			statusType := reflect.TypeOf((*gtsmodel.Status)(nil)) | ||||||
|  | 
 | ||||||
|  | 			// Generate new Status.EditIDs column definition from bun. | ||||||
|  | 			colDef, err := getBunColumnDef(tx, statusType, "EditIDs") | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			// Add EditIDs column to Status table. | ||||||
|  | 			_, err = tx.NewAddColumn(). | ||||||
|  | 				Model((*gtsmodel.Status)(nil)). | ||||||
|  | 				ColumnExpr(colDef). | ||||||
|  | 				Exec(ctx) | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			// Create the main StatusEdits table. | ||||||
|  | 			_, err = tx.NewCreateTable(). | ||||||
|  | 				IfNotExists(). | ||||||
|  | 				Model((*gtsmodel.StatusEdit)(nil)). | ||||||
|  | 				Exec(ctx) | ||||||
|  | 			return err | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	down := func(ctx context.Context, db *bun.DB) error { | ||||||
|  | 		return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { | ||||||
|  | 			return nil | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := Migrations.Register(up, down); err != nil { | ||||||
|  | 		panic(err) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | @ -0,0 +1,97 @@ | ||||||
|  | // 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 gtsmodel | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // Status represents a user-created 'post' or 'status' in the database, either remote or local | ||||||
|  | type Status struct { | ||||||
|  | 	ID                       string                      `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`                    // id of this item in the database | ||||||
|  | 	CreatedAt                time.Time                   `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created | ||||||
|  | 	UpdatedAt                time.Time                   `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated | ||||||
|  | 	FetchedAt                time.Time                   `bun:"type:timestamptz,nullzero"`                                   // when was item (remote) last fetched. | ||||||
|  | 	PinnedAt                 time.Time                   `bun:"type:timestamptz,nullzero"`                                   // Status was pinned by owning account at this time. | ||||||
|  | 	URI                      string                      `bun:",unique,nullzero,notnull"`                                    // activitypub URI of this status | ||||||
|  | 	URL                      string                      `bun:",nullzero"`                                                   // web url for viewing this status | ||||||
|  | 	Content                  string                      `bun:""`                                                            // content of this status; likely html-formatted but not guaranteed | ||||||
|  | 	AttachmentIDs            []string                    `bun:"attachments,array"`                                           // Database IDs of any media attachments associated with this status | ||||||
|  | 	Attachments              []*gtsmodel.MediaAttachment `bun:"attached_media,rel:has-many"`                                 // Attachments corresponding to attachmentIDs | ||||||
|  | 	TagIDs                   []string                    `bun:"tags,array"`                                                  // Database IDs of any tags used in this status | ||||||
|  | 	Tags                     []*gtsmodel.Tag             `bun:"attached_tags,m2m:status_to_tags"`                            // Tags corresponding to tagIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation | ||||||
|  | 	MentionIDs               []string                    `bun:"mentions,array"`                                              // Database IDs of any mentions in this status | ||||||
|  | 	Mentions                 []*gtsmodel.Mention         `bun:"attached_mentions,rel:has-many"`                              // Mentions corresponding to mentionIDs | ||||||
|  | 	EmojiIDs                 []string                    `bun:"emojis,array"`                                                // Database IDs of any emojis used in this status | ||||||
|  | 	Emojis                   []*gtsmodel.Emoji           `bun:"attached_emojis,m2m:status_to_emojis"`                        // Emojis corresponding to emojiIDs. https://bun.uptrace.dev/guide/relations.html#many-to-many-relation | ||||||
|  | 	Local                    *bool                       `bun:",nullzero,notnull,default:false"`                             // is this status from a local account? | ||||||
|  | 	AccountID                string                      `bun:"type:CHAR(26),nullzero,notnull"`                              // which account posted this status? | ||||||
|  | 	Account                  *gtsmodel.Account           `bun:"rel:belongs-to"`                                              // account corresponding to accountID | ||||||
|  | 	AccountURI               string                      `bun:",nullzero,notnull"`                                           // activitypub uri of the owner of this status | ||||||
|  | 	InReplyToID              string                      `bun:"type:CHAR(26),nullzero"`                                      // id of the status this status replies to | ||||||
|  | 	InReplyToURI             string                      `bun:",nullzero"`                                                   // activitypub uri of the status this status is a reply to | ||||||
|  | 	InReplyToAccountID       string                      `bun:"type:CHAR(26),nullzero"`                                      // id of the account that this status replies to | ||||||
|  | 	InReplyTo                *Status                     `bun:"-"`                                                           // status corresponding to inReplyToID | ||||||
|  | 	InReplyToAccount         *gtsmodel.Account           `bun:"rel:belongs-to"`                                              // account corresponding to inReplyToAccountID | ||||||
|  | 	BoostOfID                string                      `bun:"type:CHAR(26),nullzero"`                                      // id of the status this status is a boost of | ||||||
|  | 	BoostOfURI               string                      `bun:"-"`                                                           // URI of the status this status is a boost of; field not inserted in the db, just for dereferencing purposes. | ||||||
|  | 	BoostOfAccountID         string                      `bun:"type:CHAR(26),nullzero"`                                      // id of the account that owns the boosted status | ||||||
|  | 	BoostOf                  *Status                     `bun:"-"`                                                           // status that corresponds to boostOfID | ||||||
|  | 	BoostOfAccount           *gtsmodel.Account           `bun:"rel:belongs-to"`                                              // account that corresponds to boostOfAccountID | ||||||
|  | 	ThreadID                 string                      `bun:"type:CHAR(26),nullzero"`                                      // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null | ||||||
|  | 	EditIDs                  []string                    `bun:"edits,array"`                                                 // | ||||||
|  | 	Edits                    []*StatusEdit               `bun:"-"`                                                           // | ||||||
|  | 	PollID                   string                      `bun:"type:CHAR(26),nullzero"`                                      // | ||||||
|  | 	Poll                     *gtsmodel.Poll              `bun:"-"`                                                           // | ||||||
|  | 	ContentWarning           string                      `bun:",nullzero"`                                                   // cw string for this status | ||||||
|  | 	Visibility               Visibility                  `bun:",nullzero,notnull"`                                           // visibility entry for this status | ||||||
|  | 	Sensitive                *bool                       `bun:",nullzero,notnull,default:false"`                             // mark the status as sensitive? | ||||||
|  | 	Language                 string                      `bun:",nullzero"`                                                   // what language is this status written in? | ||||||
|  | 	CreatedWithApplicationID string                      `bun:"type:CHAR(26),nullzero"`                                      // Which application was used to create this status? | ||||||
|  | 	CreatedWithApplication   *gtsmodel.Application       `bun:"rel:belongs-to"`                                              // application corresponding to createdWithApplicationID | ||||||
|  | 	ActivityStreamsType      string                      `bun:",nullzero,notnull"`                                           // What is the activitystreams type of this status? See: https://www.w3.org/TR/activitystreams-vocabulary/#object-types. Will probably almost always be Note but who knows!. | ||||||
|  | 	Text                     string                      `bun:""`                                                            // Original text of the status without formatting | ||||||
|  | 	Federated                *bool                       `bun:",notnull"`                                                    // This status will be federated beyond the local timeline(s) | ||||||
|  | 	InteractionPolicy        *gtsmodel.InteractionPolicy `bun:""`                                                            // InteractionPolicy for this status. If null then the default InteractionPolicy should be assumed for this status's Visibility. Always null for boost wrappers. | ||||||
|  | 	PendingApproval          *bool                       `bun:",nullzero,notnull,default:false"`                             // If true then status is a reply or boost wrapper that must be Approved by the reply-ee or boost-ee before being fully distributed. | ||||||
|  | 	PreApproved              bool                        `bun:"-"`                                                           // If true, then status is a reply to or boost wrapper of a status on our instance, has permission to do the interaction, and an Accept should be sent out for it immediately. Field not stored in the DB. | ||||||
|  | 	ApprovedByURI            string                      `bun:",nullzero"`                                                   // URI of an Accept Activity that approves the Announce or Create Activity that this status was/will be attached to. | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Visibility represents the visibility granularity of a status. | ||||||
|  | type Visibility string | ||||||
|  | 
 | ||||||
|  | const ( | ||||||
|  | 	// VisibilityNone means nobody can see this. | ||||||
|  | 	// It's only used for web status visibility. | ||||||
|  | 	VisibilityNone Visibility = "none" | ||||||
|  | 	// VisibilityPublic means this status will be visible to everyone on all timelines. | ||||||
|  | 	VisibilityPublic Visibility = "public" | ||||||
|  | 	// VisibilityUnlocked means this status will be visible to everyone, but will only show on home timeline to followers, and in lists. | ||||||
|  | 	VisibilityUnlocked Visibility = "unlocked" | ||||||
|  | 	// VisibilityFollowersOnly means this status is viewable to followers only. | ||||||
|  | 	VisibilityFollowersOnly Visibility = "followers_only" | ||||||
|  | 	// VisibilityMutualsOnly means this status is visible to mutual followers only. | ||||||
|  | 	VisibilityMutualsOnly Visibility = "mutuals_only" | ||||||
|  | 	// VisibilityDirect means this status is visible only to mentioned recipients. | ||||||
|  | 	VisibilityDirect Visibility = "direct" | ||||||
|  | 	// VisibilityDefault is used when no other setting can be found. | ||||||
|  | 	VisibilityDefault Visibility = VisibilityUnlocked | ||||||
|  | ) | ||||||
|  | @ -0,0 +1,48 @@ | ||||||
|  | // 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 gtsmodel | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"time" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // StatusEdit represents a **historical** view of a Status | ||||||
|  | // after a received edit. The Status itself will always | ||||||
|  | // contain the latest up-to-date information. | ||||||
|  | // | ||||||
|  | // Note that stored status edits may not exactly match that | ||||||
|  | // of the origin server, they are a best-effort by receiver | ||||||
|  | // to store version history. There is no AP history endpoint. | ||||||
|  | type StatusEdit struct { | ||||||
|  | 	ID                     string    `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`                    // ID of this item in the database. | ||||||
|  | 	Content                string    `bun:""`                                                            // Content of status at time of edit; likely html-formatted but not guaranteed. | ||||||
|  | 	ContentWarning         string    `bun:",nullzero"`                                                   // Content warning of status at time of edit. | ||||||
|  | 	Text                   string    `bun:""`                                                            // Original status text, without formatting, at time of edit. | ||||||
|  | 	Language               string    `bun:",nullzero"`                                                   // Status language at time of edit. | ||||||
|  | 	Sensitive              *bool     `bun:",nullzero,notnull,default:false"`                             // Status sensitive flag at time of edit. | ||||||
|  | 	AttachmentIDs          []string  `bun:"attachments,array"`                                           // Database IDs of media attachments associated with status at time of edit. | ||||||
|  | 	AttachmentDescriptions []string  `bun:",array"`                                                      // Previous media descriptions of media attachments associated with status at time of edit. | ||||||
|  | 	PollOptions            []string  `bun:",array"`                                                      // Poll options of status at time of edit, only set if status contains a poll. | ||||||
|  | 	PollVotes              []int     `bun:",array"`                                                      // Poll vote count at time of status edit, only set if poll votes were reset. | ||||||
|  | 	StatusID               string    `bun:"type:CHAR(26),nullzero,notnull"`                              // The originating status ID this is a historical edit of. | ||||||
|  | 	CreatedAt              time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // The creation time of this version of the status content (according to receiving server). | ||||||
|  | 
 | ||||||
|  | 	// We don't bother having a *gtsmodel.Status model here | ||||||
|  | 	// as the StatusEdit is always just attached to a Status, | ||||||
|  | 	// so it doesn't need a self-reference back to it. | ||||||
|  | } | ||||||
|  | @ -19,12 +19,9 @@ package migrations | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"errors" |  | ||||||
| 
 | 
 | ||||||
| 	old_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints" | 	old_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/db/bundb/migrations/20241121121623_enum_strings_to_ints" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" |  | ||||||
| 	new_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	new_gtsmodel "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/log" |  | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/util" | 	"github.com/superseriousbusiness/gotosocial/internal/util" | ||||||
| 
 | 
 | ||||||
| 	"github.com/uptrace/bun" | 	"github.com/uptrace/bun" | ||||||
|  | @ -128,97 +125,6 @@ func init() { | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // convertEnums performs a transaction that converts |  | ||||||
| // a table's column of our old-style enums (strings) to |  | ||||||
| // more performant and space-saving integer types. |  | ||||||
| func convertEnums[OldType ~string, NewType ~int16]( |  | ||||||
| 	ctx context.Context, |  | ||||||
| 	tx bun.Tx, |  | ||||||
| 	table string, |  | ||||||
| 	column string, |  | ||||||
| 	mapping map[OldType]NewType, |  | ||||||
| 	defaultValue *NewType, |  | ||||||
| ) error { |  | ||||||
| 	if len(mapping) == 0 { |  | ||||||
| 		return errors.New("empty mapping") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Generate new column name. |  | ||||||
| 	newColumn := column + "_new" |  | ||||||
| 
 |  | ||||||
| 	log.Infof(ctx, "converting %s.%s enums; "+ |  | ||||||
| 		"this may take a while, please don't interrupt!", |  | ||||||
| 		table, column, |  | ||||||
| 	) |  | ||||||
| 
 |  | ||||||
| 	// Ensure a default value. |  | ||||||
| 	if defaultValue == nil { |  | ||||||
| 		var zero NewType |  | ||||||
| 		defaultValue = &zero |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Add new column to database. |  | ||||||
| 	if _, err := tx.NewAddColumn(). |  | ||||||
| 		Table(table). |  | ||||||
| 		ColumnExpr("? SMALLINT NOT NULL DEFAULT ?", |  | ||||||
| 			bun.Ident(newColumn), |  | ||||||
| 			*defaultValue). |  | ||||||
| 		Exec(ctx); err != nil { |  | ||||||
| 		return gtserror.Newf("error adding new column: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Get a count of all in table. |  | ||||||
| 	total, err := tx.NewSelect(). |  | ||||||
| 		Table(table). |  | ||||||
| 		Count(ctx) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return gtserror.Newf("error selecting total count: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	var updated int |  | ||||||
| 	for old, new := range mapping { |  | ||||||
| 
 |  | ||||||
| 		// Update old to new values. |  | ||||||
| 		res, err := tx.NewUpdate(). |  | ||||||
| 			Table(table). |  | ||||||
| 			Where("? = ?", bun.Ident(column), old). |  | ||||||
| 			Set("? = ?", bun.Ident(newColumn), new). |  | ||||||
| 			Exec(ctx) |  | ||||||
| 		if err != nil { |  | ||||||
| 			return gtserror.Newf("error updating old column values: %w", err) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// Count number items updated. |  | ||||||
| 		n, _ := res.RowsAffected() |  | ||||||
| 		updated += int(n) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Check total updated. |  | ||||||
| 	if total != updated { |  | ||||||
| 		log.Warnf(ctx, "total=%d does not match updated=%d", total, updated) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Drop the old column from table. |  | ||||||
| 	if _, err := tx.NewDropColumn(). |  | ||||||
| 		Table(table). |  | ||||||
| 		ColumnExpr("?", bun.Ident(column)). |  | ||||||
| 		Exec(ctx); err != nil { |  | ||||||
| 		return gtserror.Newf("error dropping old column: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	// Rename new to old name. |  | ||||||
| 	if _, err := tx.NewRaw( |  | ||||||
| 		"ALTER TABLE ? RENAME COLUMN ? TO ?", |  | ||||||
| 		bun.Ident(table), |  | ||||||
| 		bun.Ident(newColumn), |  | ||||||
| 		bun.Ident(column), |  | ||||||
| 	).Exec(ctx); err != nil { |  | ||||||
| 		return gtserror.Newf("error renaming new column: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return nil |  | ||||||
| } |  | ||||||
| 
 |  | ||||||
| // visibilityEnumMapping maps old Visibility enum values to their newer integer type. | // visibilityEnumMapping maps old Visibility enum values to their newer integer type. | ||||||
| func visibilityEnumMapping[T ~string]() map[T]new_gtsmodel.Visibility { | func visibilityEnumMapping[T ~string]() map[T]new_gtsmodel.Visibility { | ||||||
| 	return map[T]new_gtsmodel.Visibility{ | 	return map[T]new_gtsmodel.Visibility{ | ||||||
|  |  | ||||||
|  | @ -0,0 +1,57 @@ | ||||||
|  | // 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" | ||||||
|  | 
 | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/uptrace/bun" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | func init() { | ||||||
|  | 	up := func(ctx context.Context, db *bun.DB) error { | ||||||
|  | 		return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { | ||||||
|  | 
 | ||||||
|  | 			// Check for 'updated_at' column on media attachments table, else return. | ||||||
|  | 			exists, err := doesColumnExist(ctx, tx, "media_attachments", "updated_at") | ||||||
|  | 			if err != nil { | ||||||
|  | 				return err | ||||||
|  | 			} else if !exists { | ||||||
|  | 				return nil | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			// Remove 'updated_at' column. | ||||||
|  | 			_, err = tx.NewDropColumn(). | ||||||
|  | 				Model((*gtsmodel.MediaAttachment)(nil)). | ||||||
|  | 				Column("updated_at"). | ||||||
|  | 				Exec(ctx) | ||||||
|  | 			return err | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	down := func(ctx context.Context, db *bun.DB) error { | ||||||
|  | 		return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { | ||||||
|  | 			return nil | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := Migrations.Register(up, down); err != nil { | ||||||
|  | 		panic(err) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | @ -19,11 +19,209 @@ package migrations | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"errors" | ||||||
|  | 	"fmt" | ||||||
|  | 	"reflect" | ||||||
|  | 	"strconv" | ||||||
|  | 	"strings" | ||||||
| 
 | 
 | ||||||
|  | 	"codeberg.org/gruf/go-byteutil" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||||
| 	"github.com/uptrace/bun" | 	"github.com/uptrace/bun" | ||||||
| 	"github.com/uptrace/bun/dialect" | 	"github.com/uptrace/bun/dialect" | ||||||
|  | 	"github.com/uptrace/bun/dialect/feature" | ||||||
|  | 	"github.com/uptrace/bun/dialect/sqltype" | ||||||
|  | 	"github.com/uptrace/bun/schema" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | // convertEnums performs a transaction that converts | ||||||
|  | // a table's column of our old-style enums (strings) to | ||||||
|  | // more performant and space-saving integer types. | ||||||
|  | func convertEnums[OldType ~string, NewType ~int16]( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	tx bun.Tx, | ||||||
|  | 	table string, | ||||||
|  | 	column string, | ||||||
|  | 	mapping map[OldType]NewType, | ||||||
|  | 	defaultValue *NewType, | ||||||
|  | ) error { | ||||||
|  | 	if len(mapping) == 0 { | ||||||
|  | 		return errors.New("empty mapping") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Generate new column name. | ||||||
|  | 	newColumn := column + "_new" | ||||||
|  | 
 | ||||||
|  | 	log.Infof(ctx, "converting %s.%s enums; "+ | ||||||
|  | 		"this may take a while, please don't interrupt!", | ||||||
|  | 		table, column, | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	// Ensure a default value. | ||||||
|  | 	if defaultValue == nil { | ||||||
|  | 		var zero NewType | ||||||
|  | 		defaultValue = &zero | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Add new column to database. | ||||||
|  | 	if _, err := tx.NewAddColumn(). | ||||||
|  | 		Table(table). | ||||||
|  | 		ColumnExpr("? SMALLINT NOT NULL DEFAULT ?", | ||||||
|  | 			bun.Ident(newColumn), | ||||||
|  | 			*defaultValue). | ||||||
|  | 		Exec(ctx); err != nil { | ||||||
|  | 		return gtserror.Newf("error adding new column: %w", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Get a count of all in table. | ||||||
|  | 	total, err := tx.NewSelect(). | ||||||
|  | 		Table(table). | ||||||
|  | 		Count(ctx) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return gtserror.Newf("error selecting total count: %w", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	var updated int | ||||||
|  | 	for old, new := range mapping { | ||||||
|  | 
 | ||||||
|  | 		// Update old to new values. | ||||||
|  | 		res, err := tx.NewUpdate(). | ||||||
|  | 			Table(table). | ||||||
|  | 			Where("? = ?", bun.Ident(column), old). | ||||||
|  | 			Set("? = ?", bun.Ident(newColumn), new). | ||||||
|  | 			Exec(ctx) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return gtserror.Newf("error updating old column values: %w", err) | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Count number items updated. | ||||||
|  | 		n, _ := res.RowsAffected() | ||||||
|  | 		updated += int(n) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Check total updated. | ||||||
|  | 	if total != updated { | ||||||
|  | 		log.Warnf(ctx, "total=%d does not match updated=%d", total, updated) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Drop the old column from table. | ||||||
|  | 	if _, err := tx.NewDropColumn(). | ||||||
|  | 		Table(table). | ||||||
|  | 		ColumnExpr("?", bun.Ident(column)). | ||||||
|  | 		Exec(ctx); err != nil { | ||||||
|  | 		return gtserror.Newf("error dropping old column: %w", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Rename new to old name. | ||||||
|  | 	if _, err := tx.NewRaw( | ||||||
|  | 		"ALTER TABLE ? RENAME COLUMN ? TO ?", | ||||||
|  | 		bun.Ident(table), | ||||||
|  | 		bun.Ident(newColumn), | ||||||
|  | 		bun.Ident(column), | ||||||
|  | 	).Exec(ctx); err != nil { | ||||||
|  | 		return gtserror.Newf("error renaming new column: %w", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // getBunColumnDef generates a column definition string for the SQL table represented by | ||||||
|  | // Go type, with the SQL column represented by the given Go field name. This ensures when | ||||||
|  | // adding a new column for table by migration that it will end up as bun would create it. | ||||||
|  | // | ||||||
|  | // NOTE: this function must stay in sync with (*bun.CreateTableQuery{}).AppendQuery(), | ||||||
|  | // specifically where it loops over table fields appending each column definition. | ||||||
|  | func getBunColumnDef(db bun.IDB, rtype reflect.Type, fieldName string) (string, error) { | ||||||
|  | 	d := db.Dialect() | ||||||
|  | 	f := d.Features() | ||||||
|  | 
 | ||||||
|  | 	// Get bun schema definitions for Go type and its field. | ||||||
|  | 	field, table, err := getModelField(db, rtype, fieldName) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return "", err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Start with reasonable buf. | ||||||
|  | 	buf := make([]byte, 0, 64) | ||||||
|  | 
 | ||||||
|  | 	// Start with the SQL column name. | ||||||
|  | 	buf = append(buf, field.SQLName...) | ||||||
|  | 	buf = append(buf, " "...) | ||||||
|  | 
 | ||||||
|  | 	// Append the SQL | ||||||
|  | 	// type information. | ||||||
|  | 	switch { | ||||||
|  | 
 | ||||||
|  | 	// Most of the time these two will match, but for the cases where DiscoveredSQLType is dialect-specific, | ||||||
|  | 	// e.g. pgdialect would change sqltype.SmallInt to pgTypeSmallSerial for columns that have `bun:",autoincrement"` | ||||||
|  | 	case !strings.EqualFold(field.CreateTableSQLType, field.DiscoveredSQLType): | ||||||
|  | 		buf = append(buf, field.CreateTableSQLType...) | ||||||
|  | 
 | ||||||
|  | 	// For all common SQL types except VARCHAR, both UserDefinedSQLType and DiscoveredSQLType specify the correct type, | ||||||
|  | 	// and we needn't modify it. For VARCHAR columns, we will stop to check if a valid length has been set in .Varchar(int). | ||||||
|  | 	case !strings.EqualFold(field.CreateTableSQLType, sqltype.VarChar): | ||||||
|  | 		buf = append(buf, field.CreateTableSQLType...) | ||||||
|  | 
 | ||||||
|  | 	// All else falls back | ||||||
|  | 	// to a default varchar. | ||||||
|  | 	default: | ||||||
|  | 		if d.Name() == dialect.Oracle { | ||||||
|  | 			buf = append(buf, "VARCHAR2"...) | ||||||
|  | 		} else { | ||||||
|  | 			buf = append(buf, sqltype.VarChar...) | ||||||
|  | 		} | ||||||
|  | 		buf = append(buf, "("...) | ||||||
|  | 		buf = strconv.AppendInt(buf, int64(d.DefaultVarcharLen()), 10) | ||||||
|  | 		buf = append(buf, ")"...) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Append not null definition if field requires. | ||||||
|  | 	if field.NotNull && d.Name() != dialect.Oracle { | ||||||
|  | 		buf = append(buf, " NOT NULL"...) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Append autoincrement definition if field requires. | ||||||
|  | 	if field.Identity && f.Has(feature.GeneratedIdentity) || | ||||||
|  | 		(field.AutoIncrement && (f.Has(feature.AutoIncrement) || f.Has(feature.Identity))) { | ||||||
|  | 		buf = d.AppendSequence(buf, table, field) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Append any default value. | ||||||
|  | 	if field.SQLDefault != "" { | ||||||
|  | 		buf = append(buf, " DEFAULT "...) | ||||||
|  | 		buf = append(buf, field.SQLDefault...) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return byteutil.B2S(buf), nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // getModelField returns the uptrace/bun schema details for given Go type and field name. | ||||||
|  | func getModelField(db bun.IDB, rtype reflect.Type, fieldName string) (*schema.Field, *schema.Table, error) { | ||||||
|  | 
 | ||||||
|  | 	// Get the associated table for Go type. | ||||||
|  | 	table := db.Dialect().Tables().Get(rtype) | ||||||
|  | 	if table == nil { | ||||||
|  | 		return nil, nil, fmt.Errorf("no table found for type: %s", rtype) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	var field *schema.Field | ||||||
|  | 
 | ||||||
|  | 	// Look for field matching Go name. | ||||||
|  | 	for i := range table.Fields { | ||||||
|  | 		if table.Fields[i].GoName == fieldName { | ||||||
|  | 			field = table.Fields[i] | ||||||
|  | 			break | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if field == nil { | ||||||
|  | 		return nil, nil, fmt.Errorf("no bun field found on %s with name: %s", rtype, fieldName) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return field, table, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // doesColumnExist safely checks whether given column exists on table, handling both SQLite and PostgreSQL appropriately. | // doesColumnExist safely checks whether given column exists on table, handling both SQLite and PostgreSQL appropriately. | ||||||
| func doesColumnExist(ctx context.Context, tx bun.Tx, table, col string) (bool, error) { | func doesColumnExist(ctx context.Context, tx bun.Tx, table, col string) (bool, error) { | ||||||
| 	var n int | 	var n int | ||||||
|  |  | ||||||
|  | @ -21,7 +21,6 @@ import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"errors" | 	"errors" | ||||||
| 	"slices" | 	"slices" | ||||||
| 	"time" |  | ||||||
| 
 | 
 | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/db" | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" | 	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" | ||||||
|  | @ -181,7 +180,7 @@ func (s *statusDB) getStatus(ctx context.Context, lookup string, dbQuery func(*g | ||||||
| func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status) error { | func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status) error { | ||||||
| 	var ( | 	var ( | ||||||
| 		err  error | 		err  error | ||||||
| 		errs = gtserror.NewMultiError(9) | 		errs gtserror.MultiError | ||||||
| 	) | 	) | ||||||
| 
 | 
 | ||||||
| 	if status.Account == nil { | 	if status.Account == nil { | ||||||
|  | @ -257,7 +256,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status) | ||||||
| 	if !status.AttachmentsPopulated() { | 	if !status.AttachmentsPopulated() { | ||||||
| 		// Status attachments are out-of-date with IDs, repopulate. | 		// Status attachments are out-of-date with IDs, repopulate. | ||||||
| 		status.Attachments, err = s.state.DB.GetAttachmentsByIDs( | 		status.Attachments, err = s.state.DB.GetAttachmentsByIDs( | ||||||
| 			ctx, // these are already barebones | 			gtscontext.SetBarebones(ctx), | ||||||
| 			status.AttachmentIDs, | 			status.AttachmentIDs, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
|  | @ -268,7 +267,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status) | ||||||
| 	if !status.TagsPopulated() { | 	if !status.TagsPopulated() { | ||||||
| 		// Status tags are out-of-date with IDs, repopulate. | 		// Status tags are out-of-date with IDs, repopulate. | ||||||
| 		status.Tags, err = s.state.DB.GetTags( | 		status.Tags, err = s.state.DB.GetTags( | ||||||
| 			ctx, | 			gtscontext.SetBarebones(ctx), | ||||||
| 			status.TagIDs, | 			status.TagIDs, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
|  | @ -279,7 +278,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status) | ||||||
| 	if !status.MentionsPopulated() { | 	if !status.MentionsPopulated() { | ||||||
| 		// Status mentions are out-of-date with IDs, repopulate. | 		// Status mentions are out-of-date with IDs, repopulate. | ||||||
| 		status.Mentions, err = s.state.DB.GetMentions( | 		status.Mentions, err = s.state.DB.GetMentions( | ||||||
| 			ctx, // leave fully populated for now | 			ctx, // TODO: manually populate mentions for places expecting these populated | ||||||
| 			status.MentionIDs, | 			status.MentionIDs, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
|  | @ -290,7 +289,7 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status) | ||||||
| 	if !status.EmojisPopulated() { | 	if !status.EmojisPopulated() { | ||||||
| 		// Status emojis are out-of-date with IDs, repopulate. | 		// Status emojis are out-of-date with IDs, repopulate. | ||||||
| 		status.Emojis, err = s.state.DB.GetEmojisByIDs( | 		status.Emojis, err = s.state.DB.GetEmojisByIDs( | ||||||
| 			ctx, // these are already barebones | 			gtscontext.SetBarebones(ctx), | ||||||
| 			status.EmojiIDs, | 			status.EmojiIDs, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
|  | @ -298,10 +297,21 @@ func (s *statusDB) PopulateStatus(ctx context.Context, status *gtsmodel.Status) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	if !status.EditsPopulated() { | ||||||
|  | 		// Status edits are out-of-date with IDs, repopulate. | ||||||
|  | 		status.Edits, err = s.state.DB.GetStatusEditsByIDs( | ||||||
|  | 			gtscontext.SetBarebones(ctx), | ||||||
|  | 			status.EditIDs, | ||||||
|  | 		) | ||||||
|  | 		if err != nil { | ||||||
|  | 			errs.Appendf("error populating status edits: %w", err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	if status.CreatedWithApplicationID != "" && status.CreatedWithApplication == nil { | 	if status.CreatedWithApplicationID != "" && status.CreatedWithApplication == nil { | ||||||
| 		// Populate the status' expected CreatedWithApplication (not always set). | 		// Populate the status' expected CreatedWithApplication (not always set). | ||||||
| 		status.CreatedWithApplication, err = s.state.DB.GetApplicationByID( | 		status.CreatedWithApplication, err = s.state.DB.GetApplicationByID( | ||||||
| 			ctx, // these are already barebones | 			gtscontext.SetBarebones(ctx), | ||||||
| 			status.CreatedWithApplicationID, | 			status.CreatedWithApplicationID, | ||||||
| 		) | 		) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
|  | @ -350,14 +360,14 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			// change the status ID of the media attachments to the new status | 			// change the status ID of the media | ||||||
|  | 			// attachments to the current status | ||||||
| 			for _, a := range status.Attachments { | 			for _, a := range status.Attachments { | ||||||
| 				a.StatusID = status.ID | 				a.StatusID = status.ID | ||||||
| 				a.UpdatedAt = time.Now() |  | ||||||
| 				if _, err := tx. | 				if _, err := tx. | ||||||
| 					NewUpdate(). | 					NewUpdate(). | ||||||
| 					Model(a). | 					Model(a). | ||||||
| 					Column("status_id", "updated_at"). | 					Column("status_id"). | ||||||
| 					Where("? = ?", bun.Ident("media_attachment.id"), a.ID). | 					Where("? = ?", bun.Ident("media_attachment.id"), a.ID). | ||||||
| 					Exec(ctx); err != nil { | 					Exec(ctx); err != nil { | ||||||
| 					if !errors.Is(err, db.ErrAlreadyExists) { | 					if !errors.Is(err, db.ErrAlreadyExists) { | ||||||
|  | @ -384,19 +394,15 @@ func (s *statusDB) PutStatus(ctx context.Context, status *gtsmodel.Status) error | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			// Finally, insert the status | 			// Finally, insert the status | ||||||
| 			_, err := tx.NewInsert().Model(status).Exec(ctx) | 			_, err := tx.NewInsert(). | ||||||
|  | 				Model(status). | ||||||
|  | 				Exec(ctx) | ||||||
| 			return err | 			return err | ||||||
| 		}) | 		}) | ||||||
| 	}) | 	}) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, columns ...string) error { | func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, columns ...string) error { | ||||||
| 	status.UpdatedAt = time.Now() |  | ||||||
| 	if len(columns) > 0 { |  | ||||||
| 		// If we're updating by column, ensure "updated_at" is included. |  | ||||||
| 		columns = append(columns, "updated_at") |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	return s.state.Caches.DB.Status.Store(status, func() error { | 	return s.state.Caches.DB.Status.Store(status, func() error { | ||||||
| 		// It is safe to run this database transaction within cache.Store | 		// It is safe to run this database transaction within cache.Store | ||||||
| 		// as the cache does not attempt a mutex lock until AFTER hook. | 		// as the cache does not attempt a mutex lock until AFTER hook. | ||||||
|  | @ -434,13 +440,14 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, co | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			// change the status ID of the media attachments to the new status | 			// change the status ID of the media | ||||||
|  | 			// attachments to the current status. | ||||||
| 			for _, a := range status.Attachments { | 			for _, a := range status.Attachments { | ||||||
| 				a.StatusID = status.ID | 				a.StatusID = status.ID | ||||||
| 				a.UpdatedAt = time.Now() |  | ||||||
| 				if _, err := tx. | 				if _, err := tx. | ||||||
| 					NewUpdate(). | 					NewUpdate(). | ||||||
| 					Model(a). | 					Model(a). | ||||||
|  | 					Column("status_id"). | ||||||
| 					Where("? = ?", bun.Ident("media_attachment.id"), a.ID). | 					Where("? = ?", bun.Ident("media_attachment.id"), a.ID). | ||||||
| 					Exec(ctx); err != nil { | 					Exec(ctx); err != nil { | ||||||
| 					if !errors.Is(err, db.ErrAlreadyExists) { | 					if !errors.Is(err, db.ErrAlreadyExists) { | ||||||
|  | @ -467,8 +474,7 @@ func (s *statusDB) UpdateStatus(ctx context.Context, status *gtsmodel.Status, co | ||||||
| 			} | 			} | ||||||
| 
 | 
 | ||||||
| 			// Finally, update the status | 			// Finally, update the status | ||||||
| 			_, err := tx. | 			_, err := tx.NewUpdate(). | ||||||
| 				NewUpdate(). |  | ||||||
| 				Model(status). | 				Model(status). | ||||||
| 				Column(columns...). | 				Column(columns...). | ||||||
| 				Where("? = ?", bun.Ident("status.id"), status.ID). | 				Where("? = ?", bun.Ident("status.id"), status.ID). | ||||||
|  |  | ||||||
							
								
								
									
										198
									
								
								internal/db/bundb/statusedit.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										198
									
								
								internal/db/bundb/statusedit.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,198 @@ | ||||||
|  | // 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 bundb | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"errors" | ||||||
|  | 	"slices" | ||||||
|  | 
 | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtscontext" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtserror" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/state" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/util/xslices" | ||||||
|  | 	"github.com/uptrace/bun" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type statusEditDB struct { | ||||||
|  | 	db    *bun.DB | ||||||
|  | 	state *state.State | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *statusEditDB) GetStatusEditByID(ctx context.Context, id string) (*gtsmodel.StatusEdit, error) { | ||||||
|  | 	// Fetch edit from database cache with loader callback. | ||||||
|  | 	edit, err := s.state.Caches.DB.StatusEdit.LoadOne("ID", | ||||||
|  | 		func() (*gtsmodel.StatusEdit, error) { | ||||||
|  | 			var edit gtsmodel.StatusEdit | ||||||
|  | 
 | ||||||
|  | 			// Not cached, load edit | ||||||
|  | 			// from database by its ID. | ||||||
|  | 			if err := s.db.NewSelect(). | ||||||
|  | 				Model(&edit). | ||||||
|  | 				Where("? = ?", bun.Ident("id"), id). | ||||||
|  | 				Scan(ctx); err != nil { | ||||||
|  | 				return nil, err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			return &edit, nil | ||||||
|  | 		}, id, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if gtscontext.Barebones(ctx) { | ||||||
|  | 		// no need to fully populate. | ||||||
|  | 		return edit, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Further populate the edit fields where applicable. | ||||||
|  | 	if err := s.PopulateStatusEdit(ctx, edit); err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return edit, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *statusEditDB) GetStatusEditsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.StatusEdit, error) { | ||||||
|  | 	// Load status edits for IDs via cache loader callbacks. | ||||||
|  | 	edits, err := s.state.Caches.DB.StatusEdit.LoadIDs("ID", | ||||||
|  | 		ids, | ||||||
|  | 		func(uncached []string) ([]*gtsmodel.StatusEdit, error) { | ||||||
|  | 			// Preallocate expected length of uncached edits. | ||||||
|  | 			edits := make([]*gtsmodel.StatusEdit, 0, len(uncached)) | ||||||
|  | 
 | ||||||
|  | 			// Perform database query scanning | ||||||
|  | 			// the remaining (uncached) edit IDs. | ||||||
|  | 			if err := s.db.NewSelect(). | ||||||
|  | 				Model(&edits). | ||||||
|  | 				Where("? IN (?)", bun.Ident("id"), bun.In(uncached)). | ||||||
|  | 				Scan(ctx); err != nil { | ||||||
|  | 				return nil, err | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			return edits, nil | ||||||
|  | 		}, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Reorder the edits by their | ||||||
|  | 	// IDs to ensure in correct order. | ||||||
|  | 	getID := func(e *gtsmodel.StatusEdit) string { return e.ID } | ||||||
|  | 	xslices.OrderBy(edits, ids, getID) | ||||||
|  | 
 | ||||||
|  | 	if gtscontext.Barebones(ctx) { | ||||||
|  | 		// no need to fully populate. | ||||||
|  | 		return edits, nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Populate all loaded edits, removing those we fail to | ||||||
|  | 	// populate (removes needing so many nil checks everywhere). | ||||||
|  | 	edits = slices.DeleteFunc(edits, func(edit *gtsmodel.StatusEdit) bool { | ||||||
|  | 		if err := s.PopulateStatusEdit(ctx, edit); err != nil { | ||||||
|  | 			log.Errorf(ctx, "error populating edit %s: %v", edit.ID, err) | ||||||
|  | 			return true | ||||||
|  | 		} | ||||||
|  | 		return false | ||||||
|  | 	}) | ||||||
|  | 
 | ||||||
|  | 	return edits, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *statusEditDB) PopulateStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error { | ||||||
|  | 	var err error | ||||||
|  | 	var errs gtserror.MultiError | ||||||
|  | 
 | ||||||
|  | 	// For sub-models we only want | ||||||
|  | 	// barebones versions of them. | ||||||
|  | 	ctx = gtscontext.SetBarebones(ctx) | ||||||
|  | 
 | ||||||
|  | 	if !edit.AttachmentsPopulated() { | ||||||
|  | 		// Fetch all attachments for status edit's IDs. | ||||||
|  | 		edit.Attachments, err = s.state.DB.GetAttachmentsByIDs( | ||||||
|  | 			ctx, | ||||||
|  | 			edit.AttachmentIDs, | ||||||
|  | 		) | ||||||
|  | 		if err != nil { | ||||||
|  | 			errs.Appendf("error populating edit attachments: %w", err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return errs.Combine() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *statusEditDB) PutStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error { | ||||||
|  | 	return s.state.Caches.DB.StatusEdit.Store(edit, func() error { | ||||||
|  | 		_, err := s.db.NewInsert().Model(edit).Exec(ctx) | ||||||
|  | 		return err | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (s *statusEditDB) DeleteStatusEdits(ctx context.Context, ids []string) error { | ||||||
|  | 	// Gather necessary fields from | ||||||
|  | 	// deleted for cache invalidation. | ||||||
|  | 	deleted := make([]*gtsmodel.StatusEdit, 0, len(ids)) | ||||||
|  | 
 | ||||||
|  | 	// Delete all edits with IDs pertaining | ||||||
|  | 	// to given slice, returning status IDs. | ||||||
|  | 	if _, err := s.db.NewDelete(). | ||||||
|  | 		Model(&deleted). | ||||||
|  | 		Where("? IN (?)", bun.Ident("id"), bun.In(ids)). | ||||||
|  | 		Returning("?", bun.Ident("status_id")). | ||||||
|  | 		Exec(ctx); err != nil && | ||||||
|  | 		!errors.Is(err, db.ErrNoEntries) { | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Check for no deletes. | ||||||
|  | 	if len(deleted) == 0 { | ||||||
|  | 		return nil | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Invalidate all the cached status edits with IDs. | ||||||
|  | 	s.state.Caches.DB.StatusEdit.InvalidateIDs("ID", ids) | ||||||
|  | 
 | ||||||
|  | 	// With each invalidate hook mark status ID of | ||||||
|  | 	// edit we just called for. We only want to call | ||||||
|  | 	// invalidate hooks of edits from unique statuses. | ||||||
|  | 	invalidated := make(map[string]struct{}, 1) | ||||||
|  | 
 | ||||||
|  | 	// Invalidate the first delete manually, this | ||||||
|  | 	// opt negates need for initial hashmap lookup. | ||||||
|  | 	s.state.Caches.OnInvalidateStatusEdit(deleted[0]) | ||||||
|  | 	invalidated[deleted[0].StatusID] = struct{}{} | ||||||
|  | 
 | ||||||
|  | 	for _, edit := range deleted { | ||||||
|  | 		// Check not already called for status. | ||||||
|  | 		_, ok := invalidated[edit.StatusID] | ||||||
|  | 		if ok { | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Manually call status edit invalidate hook. | ||||||
|  | 		s.state.Caches.OnInvalidateStatusEdit(edit) | ||||||
|  | 		invalidated[edit.StatusID] = struct{}{} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
							
								
								
									
										168
									
								
								internal/db/bundb/statusedit_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								internal/db/bundb/statusedit_test.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,168 @@ | ||||||
|  | // 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 bundb_test | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"errors" | ||||||
|  | 	"reflect" | ||||||
|  | 	"slices" | ||||||
|  | 	"testing" | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/stretchr/testify/suite" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type StatusEditTestSuite struct { | ||||||
|  | 	BunDBStandardTestSuite | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (suite *StatusEditTestSuite) TestGetStatusEditBy() { | ||||||
|  | 	t := suite.T() | ||||||
|  | 
 | ||||||
|  | 	// Create a new context for this test. | ||||||
|  | 	ctx, cncl := context.WithCancel(context.Background()) | ||||||
|  | 	defer cncl() | ||||||
|  | 
 | ||||||
|  | 	// Sentinel error to mark avoiding a test case. | ||||||
|  | 	sentinelErr := errors.New("sentinel") | ||||||
|  | 
 | ||||||
|  | 	for _, edit := range suite.testStatusEdits { | ||||||
|  | 		for lookup, dbfunc := range map[string]func() (*gtsmodel.StatusEdit, error){ | ||||||
|  | 			"id": func() (*gtsmodel.StatusEdit, error) { | ||||||
|  | 				return suite.db.GetStatusEditByID(ctx, edit.ID) | ||||||
|  | 			}, | ||||||
|  | 		} { | ||||||
|  | 			// Clear database caches. | ||||||
|  | 			suite.state.Caches.Init() | ||||||
|  | 
 | ||||||
|  | 			t.Logf("checking database lookup %q", lookup) | ||||||
|  | 
 | ||||||
|  | 			// Perform database function. | ||||||
|  | 			checkEdit, err := dbfunc() | ||||||
|  | 			if err != nil { | ||||||
|  | 				if err == sentinelErr { | ||||||
|  | 					continue | ||||||
|  | 				} | ||||||
|  | 
 | ||||||
|  | 				t.Errorf("error encountered for database lookup %q: %v", lookup, err) | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			// Check received account data. | ||||||
|  | 			if !areEditsEqual(edit, checkEdit) { | ||||||
|  | 				t.Errorf("edit does not contain expected data: %+v", checkEdit) | ||||||
|  | 				continue | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (suite *StatusEditTestSuite) TestGetStatusEditsByIDs() { | ||||||
|  | 	t := suite.T() | ||||||
|  | 
 | ||||||
|  | 	// Create a new context for this test. | ||||||
|  | 	ctx, cncl := context.WithCancel(context.Background()) | ||||||
|  | 	defer cncl() | ||||||
|  | 
 | ||||||
|  | 	// editsByStatus returns all test edits by the given status with ID. | ||||||
|  | 	editsByStatus := func(status *gtsmodel.Status) []*gtsmodel.StatusEdit { | ||||||
|  | 		var edits []*gtsmodel.StatusEdit | ||||||
|  | 		for _, edit := range suite.testStatusEdits { | ||||||
|  | 			if edit.StatusID == status.ID { | ||||||
|  | 				edits = append(edits, edit) | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 		return edits | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	for _, status := range suite.testStatuses { | ||||||
|  | 		// Get test status edit models | ||||||
|  | 		// that should be found for status. | ||||||
|  | 		check := editsByStatus(status) | ||||||
|  | 
 | ||||||
|  | 		// Fetch edits for the slice of IDs attached to status from database. | ||||||
|  | 		edits, err := suite.state.DB.GetStatusEditsByIDs(ctx, status.EditIDs) | ||||||
|  | 		suite.NoError(err) | ||||||
|  | 
 | ||||||
|  | 		// Ensure both slices | ||||||
|  | 		// sorted the same. | ||||||
|  | 		sortEdits(check) | ||||||
|  | 		sortEdits(edits) | ||||||
|  | 
 | ||||||
|  | 		// Check whether slices of status edits match. | ||||||
|  | 		if !slices.EqualFunc(check, edits, areEditsEqual) { | ||||||
|  | 			t.Error("status edit slices do not match") | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (suite *StatusEditTestSuite) TestDeleteStatusEdits() { | ||||||
|  | 	// Create a new context for this test. | ||||||
|  | 	ctx, cncl := context.WithCancel(context.Background()) | ||||||
|  | 	defer cncl() | ||||||
|  | 
 | ||||||
|  | 	for _, status := range suite.testStatuses { | ||||||
|  | 		// Delete all edits for status with given IDs from database. | ||||||
|  | 		err := suite.state.DB.DeleteStatusEdits(ctx, status.EditIDs) | ||||||
|  | 		suite.NoError(err) | ||||||
|  | 
 | ||||||
|  | 		// Now attempt to fetch these edits from database, should be empty. | ||||||
|  | 		edits, err := suite.state.DB.GetStatusEditsByIDs(ctx, status.EditIDs) | ||||||
|  | 		suite.NoError(err) | ||||||
|  | 		suite.Empty(edits) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestStatusEditTestSuite(t *testing.T) { | ||||||
|  | 	suite.Run(t, new(StatusEditTestSuite)) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func areEditsEqual(e1, e2 *gtsmodel.StatusEdit) bool { | ||||||
|  | 	// Clone the 1st status edit. | ||||||
|  | 	e1Copy := new(gtsmodel.StatusEdit) | ||||||
|  | 	*e1Copy = *e1 | ||||||
|  | 	e1 = e1Copy | ||||||
|  | 
 | ||||||
|  | 	// Clone the 2nd status edit. | ||||||
|  | 	e2Copy := new(gtsmodel.StatusEdit) | ||||||
|  | 	*e2Copy = *e2 | ||||||
|  | 	e2 = e2Copy | ||||||
|  | 
 | ||||||
|  | 	// Clear populated sub-models. | ||||||
|  | 	e1.Attachments = nil | ||||||
|  | 	e2.Attachments = nil | ||||||
|  | 
 | ||||||
|  | 	// Clear database-set fields. | ||||||
|  | 	e1.CreatedAt = time.Time{} | ||||||
|  | 	e2.CreatedAt = time.Time{} | ||||||
|  | 
 | ||||||
|  | 	return reflect.DeepEqual(*e1, *e2) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func sortEdits(edits []*gtsmodel.StatusEdit) { | ||||||
|  | 	slices.SortFunc(edits, func(a, b *gtsmodel.StatusEdit) int { | ||||||
|  | 		if a.CreatedAt.Before(b.CreatedAt) { | ||||||
|  | 			return +1 | ||||||
|  | 		} else if b.CreatedAt.Before(a.CreatedAt) { | ||||||
|  | 			return -1 | ||||||
|  | 		} | ||||||
|  | 		return 0 | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | @ -123,13 +123,8 @@ func (t *timelineDB) GetHomeTimeline(ctx context.Context, accountID string, maxI | ||||||
| 	if maxID == "" || maxID >= id.Highest { | 	if maxID == "" || maxID >= id.Highest { | ||||||
| 		const future = 24 * time.Hour | 		const future = 24 * time.Hour | ||||||
| 
 | 
 | ||||||
| 		var err error |  | ||||||
| 
 |  | ||||||
| 		// don't return statuses more than 24hr in the future | 		// don't return statuses more than 24hr in the future | ||||||
| 		maxID, err = id.NewULIDFromTime(time.Now().Add(future)) | 		maxID = id.NewULIDFromTime(time.Now().Add(future)) | ||||||
| 		if err != nil { |  | ||||||
| 			return nil, err |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// return only statuses LOWER (ie., older) than maxID | 	// return only statuses LOWER (ie., older) than maxID | ||||||
|  | @ -223,13 +218,8 @@ func (t *timelineDB) GetPublicTimeline(ctx context.Context, maxID string, sinceI | ||||||
| 	if maxID == "" || maxID >= id.Highest { | 	if maxID == "" || maxID >= id.Highest { | ||||||
| 		const future = 24 * time.Hour | 		const future = 24 * time.Hour | ||||||
| 
 | 
 | ||||||
| 		var err error |  | ||||||
| 
 |  | ||||||
| 		// don't return statuses more than 24hr in the future | 		// don't return statuses more than 24hr in the future | ||||||
| 		maxID, err = id.NewULIDFromTime(time.Now().Add(future)) | 		maxID = id.NewULIDFromTime(time.Now().Add(future)) | ||||||
| 		if err != nil { |  | ||||||
| 			return nil, err |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// return only statuses LOWER (ie., older) than maxID | 	// return only statuses LOWER (ie., older) than maxID | ||||||
|  | @ -409,13 +399,8 @@ func (t *timelineDB) GetListTimeline( | ||||||
| 	if maxID == "" || maxID >= id.Highest { | 	if maxID == "" || maxID >= id.Highest { | ||||||
| 		const future = 24 * time.Hour | 		const future = 24 * time.Hour | ||||||
| 
 | 
 | ||||||
| 		var err error |  | ||||||
| 
 |  | ||||||
| 		// don't return statuses more than 24hr in the future | 		// don't return statuses more than 24hr in the future | ||||||
| 		maxID, err = id.NewULIDFromTime(time.Now().Add(future)) | 		maxID = id.NewULIDFromTime(time.Now().Add(future)) | ||||||
| 		if err != nil { |  | ||||||
| 			return nil, err |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// return only statuses LOWER (ie., older) than maxID | 	// return only statuses LOWER (ie., older) than maxID | ||||||
|  | @ -508,13 +493,8 @@ func (t *timelineDB) GetTagTimeline( | ||||||
| 	if maxID == "" || maxID >= id.Highest { | 	if maxID == "" || maxID >= id.Highest { | ||||||
| 		const future = 24 * time.Hour | 		const future = 24 * time.Hour | ||||||
| 
 | 
 | ||||||
| 		var err error |  | ||||||
| 
 |  | ||||||
| 		// don't return statuses more than 24hr in the future | 		// don't return statuses more than 24hr in the future | ||||||
| 		maxID, err = id.NewULIDFromTime(time.Now().Add(future)) | 		maxID = id.NewULIDFromTime(time.Now().Add(future)) | ||||||
| 		if err != nil { |  | ||||||
| 			return nil, err |  | ||||||
| 		} |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// return only statuses LOWER (ie., older) than maxID | 	// return only statuses LOWER (ie., older) than maxID | ||||||
|  |  | ||||||
|  | @ -37,10 +37,7 @@ type TimelineTestSuite struct { | ||||||
| 
 | 
 | ||||||
| func getFutureStatus() *gtsmodel.Status { | func getFutureStatus() *gtsmodel.Status { | ||||||
| 	theDistantFuture := time.Now().Add(876600 * time.Hour) | 	theDistantFuture := time.Now().Add(876600 * time.Hour) | ||||||
| 	id, err := id.NewULIDFromTime(theDistantFuture) | 	id := id.NewULIDFromTime(theDistantFuture) | ||||||
| 	if err != nil { |  | ||||||
| 		panic(err) |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	return >smodel.Status{ | 	return >smodel.Status{ | ||||||
| 		ID:                       id, | 		ID:                       id, | ||||||
|  | @ -182,7 +179,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineIgnoreExclusive() { | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		suite.FailNow(err.Error()) | 		suite.FailNow(err.Error()) | ||||||
| 	} | 	} | ||||||
| 	suite.checkStatuses(s, id.Highest, id.Lowest, 8) | 	suite.checkStatuses(s, id.Highest, id.Lowest, 9) | ||||||
| 
 | 
 | ||||||
| 	// Remove admin account from the exclusive list. | 	// Remove admin account from the exclusive list. | ||||||
| 	listEntry := suite.testListEntries["local_account_1_list_1_entry_2"] | 	listEntry := suite.testListEntries["local_account_1_list_1_entry_2"] | ||||||
|  | @ -196,7 +193,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineIgnoreExclusive() { | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		suite.FailNow(err.Error()) | 		suite.FailNow(err.Error()) | ||||||
| 	} | 	} | ||||||
| 	suite.checkStatuses(s, id.Highest, id.Lowest, 12) | 	suite.checkStatuses(s, id.Highest, id.Lowest, 13) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() { | func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() { | ||||||
|  | @ -228,7 +225,7 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineNoFollowing() { | ||||||
| 		suite.FailNow(err.Error()) | 		suite.FailNow(err.Error()) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	suite.checkStatuses(s, id.Highest, id.Lowest, 8) | 	suite.checkStatuses(s, id.Highest, id.Lowest, 9) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *TimelineTestSuite) TestGetHomeTimelineWithFutureStatus() { | func (suite *TimelineTestSuite) TestGetHomeTimelineWithFutureStatus() { | ||||||
|  | @ -281,8 +278,8 @@ func (suite *TimelineTestSuite) TestGetHomeTimelineFromHighest() { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	suite.checkStatuses(s, id.Highest, id.Lowest, 5) | 	suite.checkStatuses(s, id.Highest, id.Lowest, 5) | ||||||
| 	suite.Equal("01J2M1HPFSS54S60Y0KYV23KJE", s[0].ID) | 	suite.Equal("01JDPZEZ77X1NX0TY9M10BK1HM", s[0].ID) | ||||||
| 	suite.Equal("01G36SF3V6Y6V5BF9P4R7PQG7G", s[len(s)-1].ID) | 	suite.Equal("01HEN2RZ8BG29Y5Z9VJC73HZW7", s[len(s)-1].ID) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *TimelineTestSuite) TestGetListTimelineNoParams() { | func (suite *TimelineTestSuite) TestGetListTimelineNoParams() { | ||||||
|  | @ -296,7 +293,7 @@ func (suite *TimelineTestSuite) TestGetListTimelineNoParams() { | ||||||
| 		suite.FailNow(err.Error()) | 		suite.FailNow(err.Error()) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	suite.checkStatuses(s, id.Highest, id.Lowest, 12) | 	suite.checkStatuses(s, id.Highest, id.Lowest, 13) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *TimelineTestSuite) TestGetListTimelineMaxID() { | func (suite *TimelineTestSuite) TestGetListTimelineMaxID() { | ||||||
|  | @ -311,8 +308,8 @@ func (suite *TimelineTestSuite) TestGetListTimelineMaxID() { | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	suite.checkStatuses(s, id.Highest, id.Lowest, 5) | 	suite.checkStatuses(s, id.Highest, id.Lowest, 5) | ||||||
| 	suite.Equal("01HEN2PRXT0TF4YDRA64FZZRN7", s[0].ID) | 	suite.Equal("01JDPZEZ77X1NX0TY9M10BK1HM", s[0].ID) | ||||||
| 	suite.Equal("01FF25D5Q0DH7CHD57CTRS6WK0", s[len(s)-1].ID) | 	suite.Equal("01FN3VJGFH10KR7S2PB0GFJZYG", s[len(s)-1].ID) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *TimelineTestSuite) TestGetListTimelineMinID() { | func (suite *TimelineTestSuite) TestGetListTimelineMinID() { | ||||||
|  |  | ||||||
|  | @ -51,6 +51,7 @@ type DB interface { | ||||||
| 	SinBinStatus | 	SinBinStatus | ||||||
| 	Status | 	Status | ||||||
| 	StatusBookmark | 	StatusBookmark | ||||||
|  | 	StatusEdit | ||||||
| 	StatusFave | 	StatusFave | ||||||
| 	Tag | 	Tag | ||||||
| 	Thread | 	Thread | ||||||
|  |  | ||||||
							
								
								
									
										43
									
								
								internal/db/statusedit.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								internal/db/statusedit.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,43 @@ | ||||||
|  | // 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 db | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 
 | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type StatusEdit interface { | ||||||
|  | 
 | ||||||
|  | 	// GetStatusEditByID fetches the StatusEdit with given ID from the database. | ||||||
|  | 	GetStatusEditByID(ctx context.Context, id string) (*gtsmodel.StatusEdit, error) | ||||||
|  | 
 | ||||||
|  | 	// GetStatusEditsByIDs fetches all StatusEdits with given IDs from database, | ||||||
|  | 	// this is optimized and faster than multiple calls to GetStatusEditByID. | ||||||
|  | 	GetStatusEditsByIDs(ctx context.Context, ids []string) ([]*gtsmodel.StatusEdit, error) | ||||||
|  | 
 | ||||||
|  | 	// PopulateStatusEdit ensures the given StatusEdit's sub-models are populated. | ||||||
|  | 	PopulateStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error | ||||||
|  | 
 | ||||||
|  | 	// PutStatusEdit inserts the given new StatusEdit into the database. | ||||||
|  | 	PutStatusEdit(ctx context.Context, edit *gtsmodel.StatusEdit) error | ||||||
|  | 
 | ||||||
|  | 	// DeleteStatusEdits deletes the StatusEdits with given IDs from the database. | ||||||
|  | 	DeleteStatusEdits(ctx context.Context, ids []string) error | ||||||
|  | } | ||||||
|  | @ -87,7 +87,7 @@ func (d *Dereferencer) EnrichAnnounce( | ||||||
| 	boost.Federated = target.Federated | 	boost.Federated = target.Federated | ||||||
| 
 | 
 | ||||||
| 	// Ensure this Announce is permitted by the Announcee. | 	// Ensure this Announce is permitted by the Announcee. | ||||||
| 	permit, err := d.isPermittedStatus(ctx, requestUser, nil, boost) | 	permit, err := d.isPermittedStatus(ctx, requestUser, nil, boost, true) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, gtserror.Newf("error checking permitted status %s: %w", boost.URI, err) | 		return nil, gtserror.Newf("error checking permitted status %s: %w", boost.URI, err) | ||||||
| 	} | 	} | ||||||
|  | @ -99,10 +99,7 @@ func (d *Dereferencer) EnrichAnnounce( | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Generate an ID for the boost wrapper status. | 	// Generate an ID for the boost wrapper status. | ||||||
| 	boost.ID, err = id.NewULIDFromTime(boost.CreatedAt) | 	boost.ID = id.NewULIDFromTime(boost.CreatedAt) | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, gtserror.Newf("error generating id: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	// Store the boost wrapper status in database. | 	// Store the boost wrapper status in database. | ||||||
| 	switch err = d.state.DB.PutStatus(ctx, boost); { | 	switch err = d.state.DB.PutStatus(ctx, boost); { | ||||||
|  |  | ||||||
|  | @ -128,6 +128,7 @@ func (d *Dereferencer) RefreshMedia( | ||||||
| 	// Check emoji is up-to-date | 	// Check emoji is up-to-date | ||||||
| 	// with provided extra info. | 	// with provided extra info. | ||||||
| 	switch { | 	switch { | ||||||
|  | 	case force: | ||||||
| 	case info.Blurhash != nil && | 	case info.Blurhash != nil && | ||||||
| 		*info.Blurhash != attach.Blurhash: | 		*info.Blurhash != attach.Blurhash: | ||||||
| 		attach.Blurhash = *info.Blurhash | 		attach.Blurhash = *info.Blurhash | ||||||
|  |  | ||||||
|  | @ -302,6 +302,7 @@ func (d *Dereferencer) enrichStatusSafely( | ||||||
| 		uri, | 		uri, | ||||||
| 		status, | 		status, | ||||||
| 		statusable, | 		statusable, | ||||||
|  | 		isNew, | ||||||
| 	) | 	) | ||||||
| 
 | 
 | ||||||
| 	// Check for a returned HTTP code via error. | 	// Check for a returned HTTP code via error. | ||||||
|  | @ -374,6 +375,7 @@ func (d *Dereferencer) enrichStatus( | ||||||
| 	uri *url.URL, | 	uri *url.URL, | ||||||
| 	status *gtsmodel.Status, | 	status *gtsmodel.Status, | ||||||
| 	statusable ap.Statusable, | 	statusable ap.Statusable, | ||||||
|  | 	isNew bool, | ||||||
| ) ( | ) ( | ||||||
| 	*gtsmodel.Status, | 	*gtsmodel.Status, | ||||||
| 	ap.Statusable, | 	ap.Statusable, | ||||||
|  | @ -476,8 +478,7 @@ func (d *Dereferencer) enrichStatus( | ||||||
| 
 | 
 | ||||||
| 	// Ensure the final parsed status URI or URL matches | 	// Ensure the final parsed status URI or URL matches | ||||||
| 	// the input URI we fetched (or received) it as. | 	// the input URI we fetched (or received) it as. | ||||||
| 	matches, err := util.URIMatches( | 	matches, err := util.URIMatches(uri, | ||||||
| 		uri, |  | ||||||
| 		append( | 		append( | ||||||
| 			ap.GetURL(statusable),      // status URL(s) | 			ap.GetURL(statusable),      // status URL(s) | ||||||
| 			ap.GetJSONLDId(statusable), // status URI | 			ap.GetJSONLDId(statusable), // status URI | ||||||
|  | @ -497,19 +498,10 @@ func (d *Dereferencer) enrichStatus( | ||||||
| 		) | 		) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	var isNew bool | 	if isNew { | ||||||
| 
 |  | ||||||
| 	// Based on the original provided |  | ||||||
| 	// status model, determine whether |  | ||||||
| 	// this is a new insert / update. |  | ||||||
| 	if isNew = (status.ID == ""); isNew { |  | ||||||
| 
 | 
 | ||||||
| 		// Generate new status ID from the provided creation date. | 		// Generate new status ID from the provided creation date. | ||||||
| 		latestStatus.ID, err = id.NewULIDFromTime(latestStatus.CreatedAt) | 		latestStatus.ID = id.NewULIDFromTime(latestStatus.CreatedAt) | ||||||
| 		if err != nil { |  | ||||||
| 			log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err) |  | ||||||
| 			latestStatus.ID = id.NewULID() // just use "now" |  | ||||||
| 		} |  | ||||||
| 	} else { | 	} else { | ||||||
| 
 | 
 | ||||||
| 		// Reuse existing status ID. | 		// Reuse existing status ID. | ||||||
|  | @ -519,7 +511,6 @@ func (d *Dereferencer) enrichStatus( | ||||||
| 	// Set latest fetch time and carry- | 	// Set latest fetch time and carry- | ||||||
| 	// over some values from "old" status. | 	// over some values from "old" status. | ||||||
| 	latestStatus.FetchedAt = time.Now() | 	latestStatus.FetchedAt = time.Now() | ||||||
| 	latestStatus.UpdatedAt = status.UpdatedAt |  | ||||||
| 	latestStatus.Local = status.Local | 	latestStatus.Local = status.Local | ||||||
| 	latestStatus.PinnedAt = status.PinnedAt | 	latestStatus.PinnedAt = status.PinnedAt | ||||||
| 
 | 
 | ||||||
|  | @ -538,8 +529,9 @@ func (d *Dereferencer) enrichStatus( | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Check if this is a permitted status we should accept. | 	// Check if this is a permitted status we should accept. | ||||||
| 	// Function also sets "PendingApproval" bool as necessary. | 	// Function also sets "PendingApproval" bool as necessary, | ||||||
| 	permit, err := d.isPermittedStatus(ctx, requestUser, status, latestStatus) | 	// and handles removal of existing statuses no longer permitted. | ||||||
|  | 	permit, err := d.isPermittedStatus(ctx, requestUser, status, latestStatus, isNew) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return nil, nil, gtserror.Newf("error checking permissibility for status %s: %w", uri, err) | 		return nil, nil, gtserror.Newf("error checking permissibility for status %s: %w", uri, err) | ||||||
| 	} | 	} | ||||||
|  | @ -550,59 +542,113 @@ func (d *Dereferencer) enrichStatus( | ||||||
| 		return nil, nil, gtserror.SetNotPermitted(err) | 		return nil, nil, gtserror.SetNotPermitted(err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Ensure the status' mentions are populated, and pass in existing to check for changes. | 	// Insert / update any attached status poll. | ||||||
| 	if err := d.fetchStatusMentions(ctx, requestUser, status, latestStatus); err != nil { | 	pollChanged, err := d.handleStatusPoll(ctx, | ||||||
|  | 		status, | ||||||
|  | 		latestStatus, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, nil, gtserror.Newf("error handling poll for status %s: %w", uri, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Populate mentions associated with status, passing | ||||||
|  | 	// in existing status to reuse old where possible. | ||||||
|  | 	// (especially important here to reduce need to dereference). | ||||||
|  | 	mentionsChanged, err := d.fetchStatusMentions(ctx, | ||||||
|  | 		requestUser, | ||||||
|  | 		status, | ||||||
|  | 		latestStatus, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
| 		return nil, nil, gtserror.Newf("error populating mentions for status %s: %w", uri, err) | 		return nil, nil, gtserror.Newf("error populating mentions for status %s: %w", uri, err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Ensure the status' poll remains consistent, else reset the poll. | 	// Ensure status in a thread is connected. | ||||||
| 	if err := d.fetchStatusPoll(ctx, status, latestStatus); err != nil { | 	threadChanged, err := d.threadStatus(ctx, | ||||||
| 		return nil, nil, gtserror.Newf("error populating poll for status %s: %w", uri, err) | 		status, | ||||||
|  | 		latestStatus, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return nil, nil, gtserror.Newf("error handling threading for status %s: %w", uri, err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Now that we know who this status replies to (handled by ASStatusToStatus) | 	// Populate tags associated with status, passing | ||||||
| 	// and who it mentions, we can add a ThreadID to it if necessary. | 	// in existing status to reuse old where possible. | ||||||
| 	if err := d.threadStatus(ctx, latestStatus); err != nil { | 	tagsChanged, err := d.fetchStatusTags(ctx, | ||||||
| 		return nil, nil, gtserror.Newf("error checking / creating threadID for status %s: %w", uri, err) | 		status, | ||||||
| 	} | 		latestStatus, | ||||||
| 
 | 	) | ||||||
| 	// Ensure the status' tags are populated, (changes are expected / okay). | 	if err != nil { | ||||||
| 	if err := d.fetchStatusTags(ctx, status, latestStatus); err != nil { |  | ||||||
| 		return nil, nil, gtserror.Newf("error populating tags for status %s: %w", uri, err) | 		return nil, nil, gtserror.Newf("error populating tags for status %s: %w", uri, err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Ensure the status' media attachments are populated, passing in existing to check for changes. | 	// Populate media attachments associated with status, | ||||||
| 	if err := d.fetchStatusAttachments(ctx, requestUser, status, latestStatus); err != nil { | 	// passing in existing status to reuse old where possible | ||||||
|  | 	// (especially important here to reduce need to dereference). | ||||||
|  | 	mediaChanged, err := d.fetchStatusAttachments(ctx, | ||||||
|  | 		requestUser, | ||||||
|  | 		status, | ||||||
|  | 		latestStatus, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
| 		return nil, nil, gtserror.Newf("error populating attachments for status %s: %w", uri, err) | 		return nil, nil, gtserror.Newf("error populating attachments for status %s: %w", uri, err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Ensure the status' emoji attachments are populated, passing in existing to check for changes. | 	// Populate emoji associated with status, passing | ||||||
| 	if err := d.fetchStatusEmojis(ctx, status, latestStatus); err != nil { | 	// in existing status to reuse old where possible | ||||||
|  | 	// (especially important here to reduce need to dereference). | ||||||
|  | 	emojiChanged, err := d.fetchStatusEmojis(ctx, | ||||||
|  | 		status, | ||||||
|  | 		latestStatus, | ||||||
|  | 	) | ||||||
|  | 	if err != nil { | ||||||
| 		return nil, nil, gtserror.Newf("error populating emojis for status %s: %w", uri, err) | 		return nil, nil, gtserror.Newf("error populating emojis for status %s: %w", uri, err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if isNew { | 	if isNew { | ||||||
| 		// This is new, put the status in the database. | 		// Simplest case, insert this new status into the database. | ||||||
| 		err := d.state.DB.PutStatus(ctx, latestStatus) | 		if err := d.state.DB.PutStatus(ctx, latestStatus); err != nil { | ||||||
| 		if err != nil { | 			return nil, nil, gtserror.Newf("error inserting new status %s: %w", uri, err) | ||||||
| 			return nil, nil, gtserror.Newf("error putting in database: %w", err) |  | ||||||
| 		} | 		} | ||||||
| 	} else { | 	} else { | ||||||
| 		// This is an existing status, update the model in the database. | 		// Check for and handle any edits to status, inserting | ||||||
| 		if err := d.state.DB.UpdateStatus(ctx, latestStatus); err != nil { | 		// historical edit if necessary. Also determines status | ||||||
| 			return nil, nil, gtserror.Newf("error updating database: %w", err) | 		// columns that need updating in below query. | ||||||
|  | 		cols, err := d.handleStatusEdit(ctx, | ||||||
|  | 			status, | ||||||
|  | 			latestStatus, | ||||||
|  | 			pollChanged, | ||||||
|  | 			mentionsChanged, | ||||||
|  | 			threadChanged, | ||||||
|  | 			tagsChanged, | ||||||
|  | 			mediaChanged, | ||||||
|  | 			emojiChanged, | ||||||
|  | 		) | ||||||
|  | 		if err != nil { | ||||||
|  | 			return nil, nil, gtserror.Newf("error handling edit for status %s: %w", uri, err) | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// With returned changed columns, now update the existing status entry. | ||||||
|  | 		if err := d.state.DB.UpdateStatus(ctx, latestStatus, cols...); err != nil { | ||||||
|  | 			return nil, nil, gtserror.Newf("error updating existing status %s: %w", uri, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return latestStatus, statusable, nil | 	return latestStatus, statusable, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // fetchStatusMentions populates the mentions on 'status', creating | ||||||
|  | // new where needed, or using unchanged mentions from 'existing' status. | ||||||
| func (d *Dereferencer) fetchStatusMentions( | func (d *Dereferencer) fetchStatusMentions( | ||||||
| 	ctx context.Context, | 	ctx context.Context, | ||||||
| 	requestUser string, | 	requestUser string, | ||||||
| 	existing *gtsmodel.Status, | 	existing *gtsmodel.Status, | ||||||
| 	status *gtsmodel.Status, | 	status *gtsmodel.Status, | ||||||
| ) error { | ) ( | ||||||
|  | 	changed bool, | ||||||
|  | 	err error, | ||||||
|  | ) { | ||||||
|  | 
 | ||||||
| 	// Allocate new slice to take the yet-to-be created mention IDs. | 	// Allocate new slice to take the yet-to-be created mention IDs. | ||||||
| 	status.MentionIDs = make([]string, len(status.Mentions)) | 	status.MentionIDs = make([]string, len(status.Mentions)) | ||||||
| 
 | 
 | ||||||
|  | @ -610,7 +656,6 @@ func (d *Dereferencer) fetchStatusMentions( | ||||||
| 		var ( | 		var ( | ||||||
| 			mention       = status.Mentions[i] | 			mention       = status.Mentions[i] | ||||||
| 			alreadyExists bool | 			alreadyExists bool | ||||||
| 			err           error |  | ||||||
| 		) | 		) | ||||||
| 
 | 
 | ||||||
| 		// Search existing status for a mention already stored, | 		// Search existing status for a mention already stored, | ||||||
|  | @ -633,19 +678,16 @@ func (d *Dereferencer) fetchStatusMentions( | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
|  | 		// Mark status as | ||||||
|  | 		// having changed. | ||||||
|  | 		changed = true | ||||||
|  | 
 | ||||||
| 		// This mention didn't exist yet. | 		// This mention didn't exist yet. | ||||||
| 		// Generate new ID according to status creation. | 		// Generate new ID according to latest update. | ||||||
| 		// TODO: update this to use "edited_at" when we add | 		mention.ID = id.NewULIDFromTime(status.UpdatedAt) | ||||||
| 		//       support for edited status revision history. |  | ||||||
| 		mention.ID, err = id.NewULIDFromTime(status.CreatedAt) |  | ||||||
| 		if err != nil { |  | ||||||
| 			log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err) |  | ||||||
| 			mention.ID = id.NewULID() // just use "now" |  | ||||||
| 		} |  | ||||||
| 
 | 
 | ||||||
| 		// Set known further mention details. | 		// Set known further mention details. | ||||||
| 		mention.CreatedAt = status.CreatedAt | 		mention.CreatedAt = status.UpdatedAt | ||||||
| 		mention.UpdatedAt = status.UpdatedAt |  | ||||||
| 		mention.OriginAccount = status.Account | 		mention.OriginAccount = status.Account | ||||||
| 		mention.OriginAccountID = status.AccountID | 		mention.OriginAccountID = status.AccountID | ||||||
| 		mention.OriginAccountURI = status.AccountURI | 		mention.OriginAccountURI = status.AccountURI | ||||||
|  | @ -657,7 +699,7 @@ func (d *Dereferencer) fetchStatusMentions( | ||||||
| 
 | 
 | ||||||
| 		// Place the new mention into the database. | 		// Place the new mention into the database. | ||||||
| 		if err := d.state.DB.PutMention(ctx, mention); err != nil { | 		if err := d.state.DB.PutMention(ctx, mention); err != nil { | ||||||
| 			return gtserror.Newf("error putting mention in database: %w", err) | 			return changed, gtserror.Newf("error putting mention in database: %w", err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		// Set the *new* mention and ID. | 		// Set the *new* mention and ID. | ||||||
|  | @ -678,17 +720,42 @@ func (d *Dereferencer) fetchStatusMentions( | ||||||
| 		i++ | 		i++ | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return changed, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (d *Dereferencer) threadStatus(ctx context.Context, status *gtsmodel.Status) error { | // threadStatus ensures that given status is threaded correctly | ||||||
| 	if status.InReplyTo != nil { | // where necessary. that is it will inherit a thread ID from the | ||||||
| 		if parentThreadID := status.InReplyTo.ThreadID; parentThreadID != "" { | // existing copy if it is threaded correctly, else it will inherit | ||||||
| 			// Simplest case: parent status | // a thread ID from a parent with existing thread, else it will | ||||||
| 			// is threaded, so inherit threadID. | // generate a new thread ID if status mentions a local account. | ||||||
| 			status.ThreadID = parentThreadID | func (d *Dereferencer) threadStatus( | ||||||
| 			return nil | 	ctx context.Context, | ||||||
|  | 	existing *gtsmodel.Status, | ||||||
|  | 	status *gtsmodel.Status, | ||||||
|  | ) ( | ||||||
|  | 	changed bool, | ||||||
|  | 	err error, | ||||||
|  | ) { | ||||||
|  | 
 | ||||||
|  | 	// Check for existing status | ||||||
|  | 	// that is already threaded. | ||||||
|  | 	if existing.ThreadID != "" { | ||||||
|  | 
 | ||||||
|  | 		// Existing is threaded correctly. | ||||||
|  | 		if existing.InReplyTo == nil || | ||||||
|  | 			existing.InReplyTo.ThreadID == existing.ThreadID { | ||||||
|  | 			status.ThreadID = existing.ThreadID | ||||||
|  | 			return false, nil | ||||||
| 		} | 		} | ||||||
|  | 
 | ||||||
|  | 		// TODO: delete incorrect thread | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Check for existing parent to inherit threading from. | ||||||
|  | 	if inReplyTo := status.InReplyTo; inReplyTo != nil && | ||||||
|  | 		inReplyTo.ThreadID != "" { | ||||||
|  | 		status.ThreadID = inReplyTo.ThreadID | ||||||
|  | 		return true, nil | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Parent wasn't threaded. If this | 	// Parent wasn't threaded. If this | ||||||
|  | @ -711,7 +778,7 @@ func (d *Dereferencer) threadStatus(ctx context.Context, status *gtsmodel.Status | ||||||
| 		// Status doesn't mention a | 		// Status doesn't mention a | ||||||
| 		// local account, so we don't | 		// local account, so we don't | ||||||
| 		// need to thread it. | 		// need to thread it. | ||||||
| 		return nil | 		return false, nil | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Status mentions a local account. | 	// Status mentions a local account. | ||||||
|  | @ -719,24 +786,30 @@ func (d *Dereferencer) threadStatus(ctx context.Context, status *gtsmodel.Status | ||||||
| 	// it to the status. | 	// it to the status. | ||||||
| 	threadID := id.NewULID() | 	threadID := id.NewULID() | ||||||
| 
 | 
 | ||||||
| 	if err := d.state.DB.PutThread( | 	// Insert new thread model into db. | ||||||
| 		ctx, | 	if err := d.state.DB.PutThread(ctx, | ||||||
| 		>smodel.Thread{ | 		>smodel.Thread{ID: threadID}, | ||||||
| 			ID: threadID, |  | ||||||
| 		}, |  | ||||||
| 	); err != nil { | 	); err != nil { | ||||||
| 		return gtserror.Newf("error inserting new thread in db: %w", err) | 		return false, gtserror.Newf("error inserting new thread in db: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Set thread on latest status. | ||||||
| 	status.ThreadID = threadID | 	status.ThreadID = threadID | ||||||
| 	return nil | 	return true, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // fetchStatusTags populates the tags on 'status', fetching existing | ||||||
|  | // from the database and creating new where needed. 'existing' is used | ||||||
|  | // to fetch tags that have not changed since previous stored status. | ||||||
| func (d *Dereferencer) fetchStatusTags( | func (d *Dereferencer) fetchStatusTags( | ||||||
| 	ctx context.Context, | 	ctx context.Context, | ||||||
| 	existing *gtsmodel.Status, | 	existing *gtsmodel.Status, | ||||||
| 	status *gtsmodel.Status, | 	status *gtsmodel.Status, | ||||||
| ) error { | ) ( | ||||||
|  | 	changed bool, | ||||||
|  | 	err error, | ||||||
|  | ) { | ||||||
|  | 
 | ||||||
| 	// Allocate new slice to take the yet-to-be determined tag IDs. | 	// Allocate new slice to take the yet-to-be determined tag IDs. | ||||||
| 	status.TagIDs = make([]string, len(status.Tags)) | 	status.TagIDs = make([]string, len(status.Tags)) | ||||||
| 
 | 
 | ||||||
|  | @ -751,10 +824,14 @@ func (d *Dereferencer) fetchStatusTags( | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
|  | 		// Mark status as | ||||||
|  | 		// having changed. | ||||||
|  | 		changed = true | ||||||
|  | 
 | ||||||
| 		// Look for existing tag with name in the database. | 		// Look for existing tag with name in the database. | ||||||
| 		existing, err := d.state.DB.GetTagByName(ctx, tag.Name) | 		existing, err := d.state.DB.GetTagByName(ctx, tag.Name) | ||||||
| 		if err != nil && !errors.Is(err, db.ErrNoEntries) { | 		if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||||
| 			return gtserror.Newf("db error getting tag %s: %w", tag.Name, err) | 			return changed, gtserror.Newf("db error getting tag %s: %w", tag.Name, err) | ||||||
| 		} else if existing != nil { | 		} else if existing != nil { | ||||||
| 			status.Tags[i] = existing | 			status.Tags[i] = existing | ||||||
| 			status.TagIDs[i] = existing.ID | 			status.TagIDs[i] = existing.ID | ||||||
|  | @ -788,106 +865,21 @@ func (d *Dereferencer) fetchStatusTags( | ||||||
| 		i++ | 		i++ | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return changed, nil | ||||||
| } |  | ||||||
| 
 |  | ||||||
| func (d *Dereferencer) fetchStatusPoll( |  | ||||||
| 	ctx context.Context, |  | ||||||
| 	existing *gtsmodel.Status, |  | ||||||
| 	status *gtsmodel.Status, |  | ||||||
| ) error { |  | ||||||
| 	var ( |  | ||||||
| 		// insertStatusPoll generates ID and inserts the poll attached to status into the database. |  | ||||||
| 		insertStatusPoll = func(ctx context.Context, status *gtsmodel.Status) error { |  | ||||||
| 			var err error |  | ||||||
| 
 |  | ||||||
| 			// Generate new ID for poll from the status CreatedAt. |  | ||||||
| 			// TODO: update this to use "edited_at" when we add |  | ||||||
| 			//       support for edited status revision history. |  | ||||||
| 			status.Poll.ID, err = id.NewULIDFromTime(status.CreatedAt) |  | ||||||
| 			if err != nil { |  | ||||||
| 				log.Errorf(ctx, "invalid created at date (falling back to 'now'): %v", err) |  | ||||||
| 				status.Poll.ID = id.NewULID() // just use "now" |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			// Update the status<->poll links. |  | ||||||
| 			status.PollID = status.Poll.ID |  | ||||||
| 			status.Poll.StatusID = status.ID |  | ||||||
| 			status.Poll.Status = status |  | ||||||
| 
 |  | ||||||
| 			// Insert this latest poll into the database. |  | ||||||
| 			err = d.state.DB.PutPoll(ctx, status.Poll) |  | ||||||
| 			if err != nil { |  | ||||||
| 				return gtserror.Newf("error putting in database: %w", err) |  | ||||||
| 			} |  | ||||||
| 
 |  | ||||||
| 			return nil |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// deleteStatusPoll deletes the poll with ID, and all attached votes, from the database. |  | ||||||
| 		deleteStatusPoll = func(ctx context.Context, pollID string) error { |  | ||||||
| 			if err := d.state.DB.DeletePollByID(ctx, pollID); err != nil { |  | ||||||
| 				return gtserror.Newf("error deleting existing poll from database: %w", err) |  | ||||||
| 			} |  | ||||||
| 			return nil |  | ||||||
| 		} |  | ||||||
| 	) |  | ||||||
| 
 |  | ||||||
| 	switch { |  | ||||||
| 	case existing.Poll == nil && status.Poll == nil: |  | ||||||
| 		// no poll before or after, nothing to do. |  | ||||||
| 		return nil |  | ||||||
| 
 |  | ||||||
| 	case existing.Poll == nil && status.Poll != nil: |  | ||||||
| 		// no previous poll, insert new poll! |  | ||||||
| 		return insertStatusPoll(ctx, status) |  | ||||||
| 
 |  | ||||||
| 	case status.Poll == nil: |  | ||||||
| 		// existing poll has been deleted, remove this. |  | ||||||
| 		return deleteStatusPoll(ctx, existing.PollID) |  | ||||||
| 
 |  | ||||||
| 	case pollChanged(existing.Poll, status.Poll): |  | ||||||
| 		// poll has changed since original, delete and reinsert new. |  | ||||||
| 		if err := deleteStatusPoll(ctx, existing.PollID); err != nil { |  | ||||||
| 			return err |  | ||||||
| 		} |  | ||||||
| 		return insertStatusPoll(ctx, status) |  | ||||||
| 
 |  | ||||||
| 	case pollUpdated(existing.Poll, status.Poll): |  | ||||||
| 		// Since we last saw it, the poll has updated! |  | ||||||
| 		// Whether that be stats, or close time. |  | ||||||
| 		poll := existing.Poll |  | ||||||
| 		poll.Closing = pollJustClosed(existing.Poll, status.Poll) |  | ||||||
| 		poll.ClosedAt = status.Poll.ClosedAt |  | ||||||
| 		poll.Voters = status.Poll.Voters |  | ||||||
| 		poll.Votes = status.Poll.Votes |  | ||||||
| 
 |  | ||||||
| 		// Update poll model in the database (specifically only the possible changed columns). |  | ||||||
| 		if err := d.state.DB.UpdatePoll(ctx, poll, "closed_at", "voters", "votes"); err != nil { |  | ||||||
| 			return gtserror.Newf("error updating poll: %w", err) |  | ||||||
| 		} |  | ||||||
| 
 |  | ||||||
| 		// Update poll on status. |  | ||||||
| 		status.PollID = poll.ID |  | ||||||
| 		status.Poll = poll |  | ||||||
| 		return nil |  | ||||||
| 
 |  | ||||||
| 	default: |  | ||||||
| 		// latest and existing |  | ||||||
| 		// polls are up to date. |  | ||||||
| 		poll := existing.Poll |  | ||||||
| 		status.PollID = poll.ID |  | ||||||
| 		status.Poll = poll |  | ||||||
| 		return nil |  | ||||||
| 	} |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // fetchStatusAttachments populates the attachments on 'status', creating new database | ||||||
|  | // entries where needed and dereferencing it, or using unchanged from 'existing' status. | ||||||
| func (d *Dereferencer) fetchStatusAttachments( | func (d *Dereferencer) fetchStatusAttachments( | ||||||
| 	ctx context.Context, | 	ctx context.Context, | ||||||
| 	requestUser string, | 	requestUser string, | ||||||
| 	existing *gtsmodel.Status, | 	existing *gtsmodel.Status, | ||||||
| 	status *gtsmodel.Status, | 	status *gtsmodel.Status, | ||||||
| ) error { | ) ( | ||||||
|  | 	changed bool, | ||||||
|  | 	err error, | ||||||
|  | ) { | ||||||
|  | 
 | ||||||
| 	// Allocate new slice to take the yet-to-be fetched attachment IDs. | 	// Allocate new slice to take the yet-to-be fetched attachment IDs. | ||||||
| 	status.AttachmentIDs = make([]string, len(status.Attachments)) | 	status.AttachmentIDs = make([]string, len(status.Attachments)) | ||||||
| 
 | 
 | ||||||
|  | @ -897,9 +889,26 @@ func (d *Dereferencer) fetchStatusAttachments( | ||||||
| 		// Look for existing media attachment with remote URL first. | 		// Look for existing media attachment with remote URL first. | ||||||
| 		existing, ok := existing.GetAttachmentByRemoteURL(placeholder.RemoteURL) | 		existing, ok := existing.GetAttachmentByRemoteURL(placeholder.RemoteURL) | ||||||
| 		if ok && existing.ID != "" { | 		if ok && existing.ID != "" { | ||||||
|  | 			var info media.AdditionalMediaInfo | ||||||
| 
 | 
 | ||||||
| 			// Ensure the existing media attachment is up-to-date and cached. | 			// Look for any difference in stored media description. | ||||||
| 			existing, err := d.updateAttachment(ctx, requestUser, existing, placeholder) | 			diff := (existing.Description != placeholder.Description) | ||||||
|  | 			if diff { | ||||||
|  | 				info.Description = &placeholder.Description | ||||||
|  | 			} | ||||||
|  | 
 | ||||||
|  | 			// If description changed, | ||||||
|  | 			// we mark media as changed. | ||||||
|  | 			changed = changed || diff | ||||||
|  | 
 | ||||||
|  | 			// Store any attachment updates and | ||||||
|  | 			// ensure media is locally cached. | ||||||
|  | 			existing, err := d.RefreshMedia(ctx, | ||||||
|  | 				requestUser, | ||||||
|  | 				existing, | ||||||
|  | 				info, | ||||||
|  | 				diff, | ||||||
|  | 			) | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 				log.Errorf(ctx, "error updating existing attachment: %v", err) | 				log.Errorf(ctx, "error updating existing attachment: %v", err) | ||||||
| 
 | 
 | ||||||
|  | @ -915,9 +924,12 @@ func (d *Dereferencer) fetchStatusAttachments( | ||||||
| 			continue | 			continue | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
|  | 		// Mark status as | ||||||
|  | 		// having changed. | ||||||
|  | 		changed = true | ||||||
|  | 
 | ||||||
| 		// Load this new media attachment. | 		// Load this new media attachment. | ||||||
| 		attachment, err := d.GetMedia( | 		attachment, err := d.GetMedia(ctx, | ||||||
| 			ctx, |  | ||||||
| 			requestUser, | 			requestUser, | ||||||
| 			status.AccountID, | 			status.AccountID, | ||||||
| 			placeholder.RemoteURL, | 			placeholder.RemoteURL, | ||||||
|  | @ -955,28 +967,34 @@ func (d *Dereferencer) fetchStatusAttachments( | ||||||
| 		i++ | 		i++ | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return changed, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // fetchStatusEmojis populates the emojis on 'status', creating new database entries | ||||||
|  | // where needed and dereferencing it, or using unchanged from 'existing' status. | ||||||
| func (d *Dereferencer) fetchStatusEmojis( | func (d *Dereferencer) fetchStatusEmojis( | ||||||
| 	ctx context.Context, | 	ctx context.Context, | ||||||
| 	existing *gtsmodel.Status, | 	existing *gtsmodel.Status, | ||||||
| 	status *gtsmodel.Status, | 	status *gtsmodel.Status, | ||||||
| ) error { | ) ( | ||||||
|  | 	changed bool, | ||||||
|  | 	err error, | ||||||
|  | ) { | ||||||
|  | 
 | ||||||
| 	// Fetch the updated emojis for our status. | 	// Fetch the updated emojis for our status. | ||||||
| 	emojis, changed, err := d.fetchEmojis(ctx, | 	emojis, changed, err := d.fetchEmojis(ctx, | ||||||
| 		existing.Emojis, | 		existing.Emojis, | ||||||
| 		status.Emojis, | 		status.Emojis, | ||||||
| 	) | 	) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return gtserror.Newf("error fetching emojis: %w", err) | 		return changed, gtserror.Newf("error fetching emojis: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if !changed { | 	if !changed { | ||||||
| 		// Use existing status emoji objects. | 		// Use existing status emoji objects. | ||||||
| 		status.EmojiIDs = existing.EmojiIDs | 		status.EmojiIDs = existing.EmojiIDs | ||||||
| 		status.Emojis = existing.Emojis | 		status.Emojis = existing.Emojis | ||||||
| 		return nil | 		return false, nil | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Set latest emojis. | 	// Set latest emojis. | ||||||
|  | @ -988,9 +1006,254 @@ func (d *Dereferencer) fetchStatusEmojis( | ||||||
| 		status.EmojiIDs[i] = emoji.ID | 		status.EmojiIDs[i] = emoji.ID | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	return true, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // handleStatusPoll handles both inserting of new status poll or the | ||||||
|  | // update of an existing poll. this handles the case of simple vote | ||||||
|  | // count updates (without being classified as a change of the poll | ||||||
|  | // itself), as well as full poll changes that delete existing instance. | ||||||
|  | func (d *Dereferencer) handleStatusPoll( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	existing *gtsmodel.Status, | ||||||
|  | 	status *gtsmodel.Status, | ||||||
|  | ) ( | ||||||
|  | 	changed bool, | ||||||
|  | 	err error, | ||||||
|  | ) { | ||||||
|  | 	switch { | ||||||
|  | 	case existing.Poll == nil && status.Poll == nil: | ||||||
|  | 		// no poll before or after, nothing to do. | ||||||
|  | 		return false, nil | ||||||
|  | 
 | ||||||
|  | 	case existing.Poll == nil && status.Poll != nil: | ||||||
|  | 		// no previous poll, insert new status poll! | ||||||
|  | 		return true, d.insertStatusPoll(ctx, status) | ||||||
|  | 
 | ||||||
|  | 	case status.Poll == nil: | ||||||
|  | 		// existing status poll has been deleted, remove this from the database. | ||||||
|  | 		if err = d.state.DB.DeletePollByID(ctx, existing.Poll.ID); err != nil { | ||||||
|  | 			err = gtserror.Newf("error deleting poll from database: %w", err) | ||||||
|  | 		} | ||||||
|  | 		return true, err | ||||||
|  | 
 | ||||||
|  | 	case pollChanged(existing.Poll, status.Poll): | ||||||
|  | 		// existing status poll has been changed, remove this from the database. | ||||||
|  | 		if err = d.state.DB.DeletePollByID(ctx, existing.Poll.ID); err != nil { | ||||||
|  | 			return true, gtserror.Newf("error deleting poll from database: %w", err) | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// insert latest poll version into database. | ||||||
|  | 		return true, d.insertStatusPoll(ctx, status) | ||||||
|  | 
 | ||||||
|  | 	case pollStateUpdated(existing.Poll, status.Poll): | ||||||
|  | 		// Since we last saw it, the poll has updated! | ||||||
|  | 		// Whether that be stats, or close time. | ||||||
|  | 		poll := existing.Poll | ||||||
|  | 		poll.Closing = pollJustClosed(existing.Poll, status.Poll) | ||||||
|  | 		poll.ClosedAt = status.Poll.ClosedAt | ||||||
|  | 		poll.Voters = status.Poll.Voters | ||||||
|  | 		poll.Votes = status.Poll.Votes | ||||||
|  | 
 | ||||||
|  | 		// Update poll model in the database (specifically only the possible changed columns). | ||||||
|  | 		if err = d.state.DB.UpdatePoll(ctx, poll, "closed_at", "voters", "votes"); err != nil { | ||||||
|  | 			return false, gtserror.Newf("error updating poll: %w", err) | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Update poll on status. | ||||||
|  | 		status.PollID = poll.ID | ||||||
|  | 		status.Poll = poll | ||||||
|  | 		return false, nil | ||||||
|  | 
 | ||||||
|  | 	default: | ||||||
|  | 		// latest and existing | ||||||
|  | 		// polls are up to date. | ||||||
|  | 		poll := existing.Poll | ||||||
|  | 		status.PollID = poll.ID | ||||||
|  | 		status.Poll = poll | ||||||
|  | 		return false, nil | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // insertStatusPoll inserts an assumed new poll attached to status into the database, this | ||||||
|  | // also handles generating new ID for the poll and setting necessary fields on the status. | ||||||
|  | func (d *Dereferencer) insertStatusPoll(ctx context.Context, status *gtsmodel.Status) error { | ||||||
|  | 	var err error | ||||||
|  | 
 | ||||||
|  | 	// Generate new ID for poll from latest updated time. | ||||||
|  | 	status.Poll.ID = id.NewULIDFromTime(status.UpdatedAt) | ||||||
|  | 
 | ||||||
|  | 	// Update the status<->poll links. | ||||||
|  | 	status.PollID = status.Poll.ID | ||||||
|  | 	status.Poll.StatusID = status.ID | ||||||
|  | 	status.Poll.Status = status | ||||||
|  | 
 | ||||||
|  | 	// Insert this latest poll into the database. | ||||||
|  | 	err = d.state.DB.PutPoll(ctx, status.Poll) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return gtserror.Newf("error putting poll in database: %w", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // handleStatusEdit compiles a list of changed status table columns between | ||||||
|  | // existing and latest status model, and where necessary inserts a historic | ||||||
|  | // edit of the status into the database to store its previous state. the | ||||||
|  | // returned slice is a list of columns requiring updating in the database. | ||||||
|  | func (d *Dereferencer) handleStatusEdit( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	existing *gtsmodel.Status, | ||||||
|  | 	status *gtsmodel.Status, | ||||||
|  | 	pollChanged bool, | ||||||
|  | 	mentionsChanged bool, | ||||||
|  | 	threadChanged bool, | ||||||
|  | 	tagsChanged bool, | ||||||
|  | 	mediaChanged bool, | ||||||
|  | 	emojiChanged bool, | ||||||
|  | ) ( | ||||||
|  | 	cols []string, | ||||||
|  | 	err error, | ||||||
|  | ) { | ||||||
|  | 	var edited bool | ||||||
|  | 
 | ||||||
|  | 	// Preallocate max slice length. | ||||||
|  | 	cols = make([]string, 0, 13) | ||||||
|  | 
 | ||||||
|  | 	// Always update `fetched_at`. | ||||||
|  | 	cols = append(cols, "fetched_at") | ||||||
|  | 
 | ||||||
|  | 	// Check for edited status content. | ||||||
|  | 	if existing.Content != status.Content { | ||||||
|  | 		cols = append(cols, "content") | ||||||
|  | 		edited = true | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Check for edited status content warning. | ||||||
|  | 	if existing.ContentWarning != status.ContentWarning { | ||||||
|  | 		cols = append(cols, "content_warning") | ||||||
|  | 		edited = true | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Check for edited status sensitive flag. | ||||||
|  | 	if *existing.Sensitive != *status.Sensitive { | ||||||
|  | 		cols = append(cols, "sensitive") | ||||||
|  | 		edited = true | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Check for edited status language tag. | ||||||
|  | 	if existing.Language != status.Language { | ||||||
|  | 		cols = append(cols, "language") | ||||||
|  | 		edited = true | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if pollChanged { | ||||||
|  | 		// Attached poll was changed. | ||||||
|  | 		cols = append(cols, "poll_id") | ||||||
|  | 		edited = true | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if mentionsChanged { | ||||||
|  | 		cols = append(cols, "mentions") // i.e. MentionIDs | ||||||
|  | 
 | ||||||
|  | 		// Mentions changed doesn't necessarily | ||||||
|  | 		// indicate an edit, it may just not have | ||||||
|  | 		// been previously populated properly. | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if threadChanged { | ||||||
|  | 		cols = append(cols, "thread_id") | ||||||
|  | 
 | ||||||
|  | 		// Thread changed doesn't necessarily | ||||||
|  | 		// indicate an edit, it may just now | ||||||
|  | 		// actually be included in a thread. | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if tagsChanged { | ||||||
|  | 		cols = append(cols, "tags") // i.e. TagIDs | ||||||
|  | 
 | ||||||
|  | 		// Tags changed doesn't necessarily | ||||||
|  | 		// indicate an edit, it may just not have | ||||||
|  | 		// been previously populated properly. | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if mediaChanged { | ||||||
|  | 		// Attached media was changed. | ||||||
|  | 		cols = append(cols, "attachments") // i.e. AttachmentIDs | ||||||
|  | 		edited = true | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if emojiChanged { | ||||||
|  | 		// Attached emojis changed. | ||||||
|  | 		cols = append(cols, "emojis") // i.e. EmojiIDs | ||||||
|  | 
 | ||||||
|  | 		// Emojis changed doesn't necessarily | ||||||
|  | 		// indicate an edit, it may just not have | ||||||
|  | 		// been previously populated properly. | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if edited { | ||||||
|  | 		// We prefer to use provided 'upated_at', but ensure | ||||||
|  | 		// it fits chronologically with creation / last update. | ||||||
|  | 		if !status.UpdatedAt.After(status.CreatedAt) || | ||||||
|  | 			!status.UpdatedAt.After(existing.UpdatedAt) { | ||||||
|  | 
 | ||||||
|  | 			// Else fallback to now as update time. | ||||||
|  | 			status.UpdatedAt = status.FetchedAt | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Status has been editted since last | ||||||
|  | 		// we saw it, take snapshot of existing. | ||||||
|  | 		var edit gtsmodel.StatusEdit | ||||||
|  | 		edit.ID = id.NewULIDFromTime(status.UpdatedAt) | ||||||
|  | 		edit.Content = existing.Content | ||||||
|  | 		edit.ContentWarning = existing.ContentWarning | ||||||
|  | 		edit.Text = existing.Text | ||||||
|  | 		edit.Language = existing.Language | ||||||
|  | 		edit.Sensitive = existing.Sensitive | ||||||
|  | 		edit.StatusID = status.ID | ||||||
|  | 
 | ||||||
|  | 		// Copy existing attachments and descriptions. | ||||||
|  | 		edit.AttachmentIDs = existing.AttachmentIDs | ||||||
|  | 		edit.Attachments = existing.Attachments | ||||||
|  | 		if l := len(existing.Attachments); l > 0 { | ||||||
|  | 			edit.AttachmentDescriptions = make([]string, l) | ||||||
|  | 			for i, attach := range existing.Attachments { | ||||||
|  | 				edit.AttachmentDescriptions[i] = attach.Description | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Edit creation is last update time. | ||||||
|  | 		edit.CreatedAt = existing.UpdatedAt | ||||||
|  | 
 | ||||||
|  | 		if existing.Poll != nil { | ||||||
|  | 			// Poll only set if existing contained them. | ||||||
|  | 			edit.PollOptions = existing.Poll.Options | ||||||
|  | 
 | ||||||
|  | 			if !*existing.Poll.HideCounts || pollChanged { | ||||||
|  | 				// If the counts are allowed to be | ||||||
|  | 				// shown, or poll has changed, then | ||||||
|  | 				// include poll vote counts in edit. | ||||||
|  | 				edit.PollVotes = existing.Poll.Votes | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Insert this new edit of existing status into database. | ||||||
|  | 		if err := d.state.DB.PutStatusEdit(ctx, &edit); err != nil { | ||||||
|  | 			return nil, gtserror.Newf("error putting edit in database: %w", err) | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Add edit to list of edits on the status. | ||||||
|  | 		status.EditIDs = append(status.EditIDs, edit.ID) | ||||||
|  | 		status.Edits = append(status.Edits, &edit) | ||||||
|  | 
 | ||||||
|  | 		// Add updated_at and edits to list of cols. | ||||||
|  | 		cols = append(cols, "updated_at", "edits") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return cols, nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // getPopulatedMention tries to populate the given | // getPopulatedMention tries to populate the given | ||||||
| // mention with the correct TargetAccount and (if not | // mention with the correct TargetAccount and (if not | ||||||
| // yet set) TargetAccountURI, returning the populated | // yet set) TargetAccountURI, returning the populated | ||||||
|  |  | ||||||
|  | @ -62,6 +62,7 @@ func (d *Dereferencer) isPermittedStatus( | ||||||
| 	requestUser string, | 	requestUser string, | ||||||
| 	existing *gtsmodel.Status, | 	existing *gtsmodel.Status, | ||||||
| 	status *gtsmodel.Status, | 	status *gtsmodel.Status, | ||||||
|  | 	isNew bool, | ||||||
| ) ( | ) ( | ||||||
| 	permitted bool, // is permitted? | 	permitted bool, // is permitted? | ||||||
| 	err error, | 	err error, | ||||||
|  | @ -98,7 +99,7 @@ func (d *Dereferencer) isPermittedStatus( | ||||||
| 		permitted = true | 		permitted = true | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if !permitted && existing != nil { | 	if !permitted && !isNew { | ||||||
| 		log.Infof(ctx, "deleting unpermitted: %s", existing.URI) | 		log.Infof(ctx, "deleting unpermitted: %s", existing.URI) | ||||||
| 
 | 
 | ||||||
| 		// Delete existing status from database as it's no longer permitted. | 		// Delete existing status from database as it's no longer permitted. | ||||||
|  | @ -110,11 +111,13 @@ func (d *Dereferencer) isPermittedStatus( | ||||||
| 	return | 	return | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // isPermittedReply ... | ||||||
| func (d *Dereferencer) isPermittedReply( | func (d *Dereferencer) isPermittedReply( | ||||||
| 	ctx context.Context, | 	ctx context.Context, | ||||||
| 	requestUser string, | 	requestUser string, | ||||||
| 	reply *gtsmodel.Status, | 	reply *gtsmodel.Status, | ||||||
| ) (bool, error) { | ) (bool, error) { | ||||||
|  | 
 | ||||||
| 	var ( | 	var ( | ||||||
| 		replyURI     = reply.URI           // Definitely set. | 		replyURI     = reply.URI           // Definitely set. | ||||||
| 		inReplyToURI = reply.InReplyToURI  // Definitely set. | 		inReplyToURI = reply.InReplyToURI  // Definitely set. | ||||||
|  | @ -149,8 +152,7 @@ func (d *Dereferencer) isPermittedReply( | ||||||
| 		// If this status's parent was rejected, | 		// If this status's parent was rejected, | ||||||
| 		// implicitly this reply should be too; | 		// implicitly this reply should be too; | ||||||
| 		// there's nothing more to check here. | 		// there's nothing more to check here. | ||||||
| 		return false, d.unpermittedByParent( | 		return false, d.unpermittedByParent(ctx, | ||||||
| 			ctx, |  | ||||||
| 			reply, | 			reply, | ||||||
| 			thisReq, | 			thisReq, | ||||||
| 			parentReq, | 			parentReq, | ||||||
|  | @ -164,6 +166,7 @@ func (d *Dereferencer) isPermittedReply( | ||||||
| 	// be approved, then we should just reject it | 	// be approved, then we should just reject it | ||||||
| 	// again, as nothing's changed since last time. | 	// again, as nothing's changed since last time. | ||||||
| 	if thisRejected && acceptIRI == "" { | 	if thisRejected && acceptIRI == "" { | ||||||
|  | 
 | ||||||
| 		// Nothing changed, | 		// Nothing changed, | ||||||
| 		// still rejected. | 		// still rejected. | ||||||
| 		return false, nil | 		return false, nil | ||||||
|  | @ -174,6 +177,7 @@ func (d *Dereferencer) isPermittedReply( | ||||||
| 	// to be approved. Continue permission checks. | 	// to be approved. Continue permission checks. | ||||||
| 
 | 
 | ||||||
| 	if inReplyTo == nil { | 	if inReplyTo == nil { | ||||||
|  | 
 | ||||||
| 		// If we didn't have the replied-to status | 		// If we didn't have the replied-to status | ||||||
| 		// in our database (yet), we can't check | 		// in our database (yet), we can't check | ||||||
| 		// right now if this reply is permitted. | 		// right now if this reply is permitted. | ||||||
|  |  | ||||||
|  | @ -21,14 +21,21 @@ import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"fmt" | 	"fmt" | ||||||
| 	"testing" | 	"testing" | ||||||
|  | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"github.com/stretchr/testify/suite" | 	"github.com/stretchr/testify/suite" | ||||||
|  | 	"github.com/superseriousbusiness/activity/streams" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/ap" | 	"github.com/superseriousbusiness/gotosocial/internal/ap" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/db" | 	"github.com/superseriousbusiness/gotosocial/internal/db" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/federation/dereferencing" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/util" | ||||||
| 	"github.com/superseriousbusiness/gotosocial/testrig" | 	"github.com/superseriousbusiness/gotosocial/testrig" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | // instantFreshness is the shortest possible freshness window. | ||||||
|  | var instantFreshness = util.Ptr(dereferencing.FreshnessWindow(0)) | ||||||
|  | 
 | ||||||
| type StatusTestSuite struct { | type StatusTestSuite struct { | ||||||
| 	DereferencerStandardTestSuite | 	DereferencerStandardTestSuite | ||||||
| } | } | ||||||
|  | @ -229,6 +236,219 @@ func (suite *StatusTestSuite) TestDereferenceStatusWithNonMatchingURI() { | ||||||
| 	suite.Nil(fetchedStatus) | 	suite.Nil(fetchedStatus) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func (suite *StatusTestSuite) TestDereferencerRefreshStatusUpdated() { | ||||||
|  | 	// Create a new context for this test. | ||||||
|  | 	ctx, cncl := context.WithCancel(context.Background()) | ||||||
|  | 	defer cncl() | ||||||
|  | 
 | ||||||
|  | 	// The local account we will be fetching statuses as. | ||||||
|  | 	fetchingAccount := suite.testAccounts["local_account_1"] | ||||||
|  | 
 | ||||||
|  | 	// The test status in question that we will be dereferencing from "remote". | ||||||
|  | 	testURIStr := "https://unknown-instance.com/users/brand_new_person/statuses/01FE4NTHKWW7THT67EF10EB839" | ||||||
|  | 	testURI := testrig.URLMustParse(testURIStr) | ||||||
|  | 	testStatusable := suite.client.TestRemoteStatuses[testURIStr] | ||||||
|  | 
 | ||||||
|  | 	// Fetch the remote status first to load it into instance. | ||||||
|  | 	testStatus, statusable, err := suite.dereferencer.GetStatusByURI(ctx, | ||||||
|  | 		fetchingAccount.Username, | ||||||
|  | 		testURI, | ||||||
|  | 	) | ||||||
|  | 	suite.NotNil(statusable) | ||||||
|  | 	suite.NoError(err) | ||||||
|  | 
 | ||||||
|  | 	// Run through multiple possible edits. | ||||||
|  | 	for _, testCase := range []struct { | ||||||
|  | 		editedContent        string | ||||||
|  | 		editedContentWarning string | ||||||
|  | 		editedLanguage       string | ||||||
|  | 		editedSensitive      bool | ||||||
|  | 		editedAttachmentIDs  []string | ||||||
|  | 		editedPollOptions    []string | ||||||
|  | 		editedPollVotes      []int | ||||||
|  | 		editedAt             time.Time | ||||||
|  | 	}{ | ||||||
|  | 		{ | ||||||
|  | 			editedContent:        "updated status content!", | ||||||
|  | 			editedContentWarning: "CW: edited status content", | ||||||
|  | 			editedLanguage:       testStatus.Language,        // no change | ||||||
|  | 			editedSensitive:      *testStatus.Sensitive,      // no change | ||||||
|  | 			editedAttachmentIDs:  testStatus.AttachmentIDs,   // no change | ||||||
|  | 			editedPollOptions:    getPollOptions(testStatus), // no change | ||||||
|  | 			editedPollVotes:      getPollVotes(testStatus),   // no change | ||||||
|  | 			editedAt:             time.Now(), | ||||||
|  | 		}, | ||||||
|  | 	} { | ||||||
|  | 		// Take a snapshot of current | ||||||
|  | 		// state of the test status. | ||||||
|  | 		testStatus = copyStatus(testStatus) | ||||||
|  | 
 | ||||||
|  | 		// Edit the "remote" statusable obj. | ||||||
|  | 		suite.editStatusable(testStatusable, | ||||||
|  | 			testCase.editedContent, | ||||||
|  | 			testCase.editedContentWarning, | ||||||
|  | 			testCase.editedLanguage, | ||||||
|  | 			testCase.editedSensitive, | ||||||
|  | 			testCase.editedAttachmentIDs, | ||||||
|  | 			testCase.editedPollOptions, | ||||||
|  | 			testCase.editedPollVotes, | ||||||
|  | 			testCase.editedAt, | ||||||
|  | 		) | ||||||
|  | 
 | ||||||
|  | 		// Refresh with a given statusable to updated to edited copy. | ||||||
|  | 		latest, statusable, err := suite.dereferencer.RefreshStatus(ctx, | ||||||
|  | 			fetchingAccount.Username, | ||||||
|  | 			testStatus, | ||||||
|  | 			nil, // NOTE: can provide testStatusable here to test as being received (not deref'd) | ||||||
|  | 			instantFreshness, | ||||||
|  | 		) | ||||||
|  | 		suite.NotNil(statusable) | ||||||
|  | 		suite.NoError(err) | ||||||
|  | 
 | ||||||
|  | 		// verify updated status details. | ||||||
|  | 		suite.verifyEditedStatusUpdate( | ||||||
|  | 
 | ||||||
|  | 			// the original status | ||||||
|  | 			// before any changes. | ||||||
|  | 			testStatus, | ||||||
|  | 
 | ||||||
|  | 			// latest status | ||||||
|  | 			// being tested. | ||||||
|  | 			latest, | ||||||
|  | 
 | ||||||
|  | 			// expected current state. | ||||||
|  | 			>smodel.StatusEdit{ | ||||||
|  | 				Content:        testCase.editedContent, | ||||||
|  | 				ContentWarning: testCase.editedContentWarning, | ||||||
|  | 				Language:       testCase.editedLanguage, | ||||||
|  | 				Sensitive:      &testCase.editedSensitive, | ||||||
|  | 				AttachmentIDs:  testCase.editedAttachmentIDs, | ||||||
|  | 				PollOptions:    testCase.editedPollOptions, | ||||||
|  | 				PollVotes:      testCase.editedPollVotes, | ||||||
|  | 				// createdAt never changes | ||||||
|  | 			}, | ||||||
|  | 
 | ||||||
|  | 			// expected historic edit. | ||||||
|  | 			>smodel.StatusEdit{ | ||||||
|  | 				Content:        testStatus.Content, | ||||||
|  | 				ContentWarning: testStatus.ContentWarning, | ||||||
|  | 				Language:       testStatus.Language, | ||||||
|  | 				Sensitive:      testStatus.Sensitive, | ||||||
|  | 				AttachmentIDs:  testStatus.AttachmentIDs, | ||||||
|  | 				PollOptions:    getPollOptions(testStatus), | ||||||
|  | 				PollVotes:      getPollVotes(testStatus), | ||||||
|  | 				CreatedAt:      testStatus.UpdatedAt, | ||||||
|  | 			}, | ||||||
|  | 		) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // editStatusable updates the given statusable attributes. | ||||||
|  | // note that this acts on the original object, no copying. | ||||||
|  | func (suite *StatusTestSuite) editStatusable( | ||||||
|  | 	statusable ap.Statusable, | ||||||
|  | 	content string, | ||||||
|  | 	contentWarning string, | ||||||
|  | 	language string, | ||||||
|  | 	sensitive bool, | ||||||
|  | 	attachmentIDs []string, // TODO: this will require some thinking as to how ... | ||||||
|  | 	pollOptions []string, // TODO: this will require changing statusable type to question | ||||||
|  | 	pollVotes []int, // TODO: this will require changing statusable type to question | ||||||
|  | 	editedAt time.Time, | ||||||
|  | ) { | ||||||
|  | 	// simply reset all mentions / emojis / tags | ||||||
|  | 	statusable.SetActivityStreamsTag(nil) | ||||||
|  | 
 | ||||||
|  | 	// Update the statusable content property + language (if set). | ||||||
|  | 	contentProp := streams.NewActivityStreamsContentProperty() | ||||||
|  | 	statusable.SetActivityStreamsContent(contentProp) | ||||||
|  | 	contentProp.AppendXMLSchemaString(content) | ||||||
|  | 	if language != "" { | ||||||
|  | 		contentProp.AppendRDFLangString(map[string]string{ | ||||||
|  | 			language: content, | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Update the statusable content-warning property. | ||||||
|  | 	summaryProp := streams.NewActivityStreamsSummaryProperty() | ||||||
|  | 	statusable.SetActivityStreamsSummary(summaryProp) | ||||||
|  | 	summaryProp.AppendXMLSchemaString(contentWarning) | ||||||
|  | 
 | ||||||
|  | 	// Update the statusable sensitive property. | ||||||
|  | 	sensitiveProp := streams.NewActivityStreamsSensitiveProperty() | ||||||
|  | 	statusable.SetActivityStreamsSensitive(sensitiveProp) | ||||||
|  | 	sensitiveProp.AppendXMLSchemaBoolean(sensitive) | ||||||
|  | 
 | ||||||
|  | 	// Update the statusable updated property. | ||||||
|  | 	ap.SetUpdated(statusable, editedAt) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // verifyEditedStatusUpdate verifies that a given status has | ||||||
|  | // the expected number of historic edits, the 'current' status | ||||||
|  | // attributes (encapsulated as an edit for minimized no. args), | ||||||
|  | // and the last given 'historic' status edit attributes. | ||||||
|  | func (suite *StatusTestSuite) verifyEditedStatusUpdate( | ||||||
|  | 	testStatus *gtsmodel.Status, // the original model | ||||||
|  | 	status *gtsmodel.Status, // the status to check | ||||||
|  | 	current *gtsmodel.StatusEdit, // expected current state | ||||||
|  | 	historic *gtsmodel.StatusEdit, // historic edit we expect to have | ||||||
|  | ) { | ||||||
|  | 	// don't use this func | ||||||
|  | 	// name in error msgs. | ||||||
|  | 	suite.T().Helper() | ||||||
|  | 
 | ||||||
|  | 	// Check we have expected number of edits. | ||||||
|  | 	previousEdits := len(testStatus.Edits) | ||||||
|  | 	suite.Len(status.Edits, previousEdits+1) | ||||||
|  | 	suite.Len(status.EditIDs, previousEdits+1) | ||||||
|  | 
 | ||||||
|  | 	// Check current state of status. | ||||||
|  | 	suite.Equal(current.Content, status.Content) | ||||||
|  | 	suite.Equal(current.ContentWarning, status.ContentWarning) | ||||||
|  | 	suite.Equal(current.Language, status.Language) | ||||||
|  | 	suite.Equal(*current.Sensitive, *status.Sensitive) | ||||||
|  | 	suite.Equal(current.AttachmentIDs, status.AttachmentIDs) | ||||||
|  | 	suite.Equal(current.PollOptions, getPollOptions(status)) | ||||||
|  | 	suite.Equal(current.PollVotes, getPollVotes(status)) | ||||||
|  | 
 | ||||||
|  | 	// Check the latest historic edit matches expected. | ||||||
|  | 	latestEdit := status.Edits[len(status.Edits)-1] | ||||||
|  | 	suite.Equal(historic.Content, latestEdit.Content) | ||||||
|  | 	suite.Equal(historic.ContentWarning, latestEdit.ContentWarning) | ||||||
|  | 	suite.Equal(historic.Language, latestEdit.Language) | ||||||
|  | 	suite.Equal(*historic.Sensitive, *latestEdit.Sensitive) | ||||||
|  | 	suite.Equal(historic.AttachmentIDs, latestEdit.AttachmentIDs) | ||||||
|  | 	suite.Equal(historic.PollOptions, latestEdit.PollOptions) | ||||||
|  | 	suite.Equal(historic.PollVotes, latestEdit.PollVotes) | ||||||
|  | 	suite.Equal(historic.CreatedAt, latestEdit.CreatedAt) | ||||||
|  | 
 | ||||||
|  | 	// The status creation date should never change. | ||||||
|  | 	suite.Equal(testStatus.CreatedAt, status.CreatedAt) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func TestStatusTestSuite(t *testing.T) { | func TestStatusTestSuite(t *testing.T) { | ||||||
| 	suite.Run(t, new(StatusTestSuite)) | 	suite.Run(t, new(StatusTestSuite)) | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // copyStatus returns a copy of the given status model (not including sub-structs). | ||||||
|  | func copyStatus(status *gtsmodel.Status) *gtsmodel.Status { | ||||||
|  | 	copy := new(gtsmodel.Status) | ||||||
|  | 	*copy = *status | ||||||
|  | 	return copy | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // getPollOptions extracts poll option strings from status (if poll is set). | ||||||
|  | func getPollOptions(status *gtsmodel.Status) []string { | ||||||
|  | 	if status.Poll != nil { | ||||||
|  | 		return status.Poll.Options | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // getPollVotes extracts poll vote counts from status (if poll is set). | ||||||
|  | func getPollVotes(status *gtsmodel.Status) []int { | ||||||
|  | 	if status.Poll != nil { | ||||||
|  | 		return status.Poll.Votes | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -52,15 +52,15 @@ func emojiChanged(existing, latest *gtsmodel.Emoji) bool { | ||||||
| 
 | 
 | ||||||
| // pollChanged returns whether a poll has changed in way that | // pollChanged returns whether a poll has changed in way that | ||||||
| // indicates that this should be an entirely new poll. i.e. if | // indicates that this should be an entirely new poll. i.e. if | ||||||
| // the available options have changed, or the expiry has increased. | // the available options have changed, or the expiry has changed. | ||||||
| func pollChanged(existing, latest *gtsmodel.Poll) bool { | func pollChanged(existing, latest *gtsmodel.Poll) bool { | ||||||
| 	return !slices.Equal(existing.Options, latest.Options) || | 	return !slices.Equal(existing.Options, latest.Options) || | ||||||
| 		!existing.ExpiresAt.Equal(latest.ExpiresAt) | 		!existing.ExpiresAt.Equal(latest.ExpiresAt) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // pollUpdated returns whether a poll has updated, i.e. if the | // pollStateUpdated returns whether a poll has updated, i.e. if | ||||||
| // vote counts have changed, or if it has expired / been closed. | // vote counts have changed, or if it has expired / been closed. | ||||||
| func pollUpdated(existing, latest *gtsmodel.Poll) bool { | func pollStateUpdated(existing, latest *gtsmodel.Poll) bool { | ||||||
| 	return *existing.Voters != *latest.Voters || | 	return *existing.Voters != *latest.Voters || | ||||||
| 		!slices.Equal(existing.Votes, latest.Votes) || | 		!slices.Equal(existing.Votes, latest.Votes) || | ||||||
| 		!existing.ClosedAt.Equal(latest.ClosedAt) | 		!existing.ClosedAt.Equal(latest.ClosedAt) | ||||||
|  |  | ||||||
|  | @ -79,7 +79,7 @@ func (suite *AnnounceTestSuite) TestAnnounceTwice() { | ||||||
| 
 | 
 | ||||||
| 	// Insert the boost-of status into the | 	// Insert the boost-of status into the | ||||||
| 	// DB cache to emulate processor handling | 	// DB cache to emulate processor handling | ||||||
| 	boost.ID, _ = id.NewULIDFromTime(boost.CreatedAt) | 	boost.ID = id.NewULIDFromTime(boost.CreatedAt) | ||||||
| 	suite.state.Caches.DB.Status.Put(boost) | 	suite.state.Caches.DB.Status.Put(boost) | ||||||
| 
 | 
 | ||||||
| 	// only the URI will be set for the boosted status | 	// only the URI will be set for the boosted status | ||||||
|  |  | ||||||
|  | @ -26,7 +26,6 @@ import ( | ||||||
| type MediaAttachment struct { | type MediaAttachment struct { | ||||||
| 	ID                string           `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`                    // id of this item in the database | 	ID                string           `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`                    // id of this item in the database | ||||||
| 	CreatedAt         time.Time        `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created | 	CreatedAt         time.Time        `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created | ||||||
| 	UpdatedAt         time.Time        `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated |  | ||||||
| 	StatusID          string           `bun:"type:CHAR(26),nullzero"`                                      // ID of the status to which this is attached | 	StatusID          string           `bun:"type:CHAR(26),nullzero"`                                      // ID of the status to which this is attached | ||||||
| 	URL               string           `bun:",nullzero"`                                                   // Where can the attachment be retrieved on *this* server | 	URL               string           `bun:",nullzero"`                                                   // Where can the attachment be retrieved on *this* server | ||||||
| 	RemoteURL         string           `bun:",nullzero"`                                                   // Where can the attachment be retrieved on a remote server (empty for local media) | 	RemoteURL         string           `bun:",nullzero"`                                                   // Where can the attachment be retrieved on a remote server (empty for local media) | ||||||
|  |  | ||||||
|  | @ -26,7 +26,6 @@ import ( | ||||||
| type Mention struct { | type Mention struct { | ||||||
| 	ID               string    `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`                    // id of this item in the database | 	ID               string    `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`                    // id of this item in the database | ||||||
| 	CreatedAt        time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created | 	CreatedAt        time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item created | ||||||
| 	UpdatedAt        time.Time `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // when was item last updated |  | ||||||
| 	StatusID         string    `bun:"type:CHAR(26),nullzero,notnull"`                              // ID of the status this mention originates from | 	StatusID         string    `bun:"type:CHAR(26),nullzero,notnull"`                              // ID of the status this mention originates from | ||||||
| 	Status           *Status   `bun:"rel:belongs-to"`                                              // status referred to by statusID | 	Status           *Status   `bun:"rel:belongs-to"`                                              // status referred to by statusID | ||||||
| 	OriginAccountID  string    `bun:"type:CHAR(26),nullzero,notnull"`                              // ID of the mention creator account | 	OriginAccountID  string    `bun:"type:CHAR(26),nullzero,notnull"`                              // ID of the mention creator account | ||||||
|  |  | ||||||
|  | @ -20,6 +20,8 @@ package gtsmodel | ||||||
| import ( | import ( | ||||||
| 	"slices" | 	"slices" | ||||||
| 	"time" | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/util/xslices" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // Status represents a user-created 'post' or 'status' in the database, either remote or local | // Status represents a user-created 'post' or 'status' in the database, either remote or local | ||||||
|  | @ -55,6 +57,8 @@ type Status struct { | ||||||
| 	BoostOf                  *Status            `bun:"-"`                                                           // status that corresponds to boostOfID | 	BoostOf                  *Status            `bun:"-"`                                                           // status that corresponds to boostOfID | ||||||
| 	BoostOfAccount           *Account           `bun:"rel:belongs-to"`                                              // account that corresponds to boostOfAccountID | 	BoostOfAccount           *Account           `bun:"rel:belongs-to"`                                              // account that corresponds to boostOfAccountID | ||||||
| 	ThreadID                 string             `bun:"type:CHAR(26),nullzero"`                                      // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null | 	ThreadID                 string             `bun:"type:CHAR(26),nullzero"`                                      // id of the thread to which this status belongs; only set for remote statuses if a local account is involved at some point in the thread, otherwise null | ||||||
|  | 	EditIDs                  []string           `bun:"edits,array"`                                                 // | ||||||
|  | 	Edits                    []*StatusEdit      `bun:"-"`                                                           // | ||||||
| 	PollID                   string             `bun:"type:CHAR(26),nullzero"`                                      // | 	PollID                   string             `bun:"type:CHAR(26),nullzero"`                                      // | ||||||
| 	Poll                     *Poll              `bun:"-"`                                                           // | 	Poll                     *Poll              `bun:"-"`                                                           // | ||||||
| 	ContentWarning           string             `bun:",nullzero"`                                                   // cw string for this status | 	ContentWarning           string             `bun:",nullzero"`                                                   // cw string for this status | ||||||
|  | @ -92,7 +96,8 @@ func (s *Status) GetBoostOfAccountID() string { | ||||||
| 	return s.BoostOfAccountID | 	return s.BoostOfAccountID | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // AttachmentsPopulated returns whether media attachments are populated according to current AttachmentIDs. | // AttachmentsPopulated returns whether media attachments | ||||||
|  | // are populated according to current AttachmentIDs. | ||||||
| func (s *Status) AttachmentsPopulated() bool { | func (s *Status) AttachmentsPopulated() bool { | ||||||
| 	if len(s.AttachmentIDs) != len(s.Attachments) { | 	if len(s.AttachmentIDs) != len(s.Attachments) { | ||||||
| 		// this is the quickest indicator. | 		// this is the quickest indicator. | ||||||
|  | @ -106,7 +111,8 @@ func (s *Status) AttachmentsPopulated() bool { | ||||||
| 	return true | 	return true | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // TagsPopulated returns whether tags are populated according to current TagIDs. | // TagsPopulated returns whether tags are | ||||||
|  | // populated according to current TagIDs. | ||||||
| func (s *Status) TagsPopulated() bool { | func (s *Status) TagsPopulated() bool { | ||||||
| 	if len(s.TagIDs) != len(s.Tags) { | 	if len(s.TagIDs) != len(s.Tags) { | ||||||
| 		// this is the quickest indicator. | 		// this is the quickest indicator. | ||||||
|  | @ -120,7 +126,8 @@ func (s *Status) TagsPopulated() bool { | ||||||
| 	return true | 	return true | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // MentionsPopulated returns whether mentions are populated according to current MentionIDs. | // MentionsPopulated returns whether mentions are | ||||||
|  | // populated according to current MentionIDs. | ||||||
| func (s *Status) MentionsPopulated() bool { | func (s *Status) MentionsPopulated() bool { | ||||||
| 	if len(s.MentionIDs) != len(s.Mentions) { | 	if len(s.MentionIDs) != len(s.Mentions) { | ||||||
| 		// this is the quickest indicator. | 		// this is the quickest indicator. | ||||||
|  | @ -134,7 +141,8 @@ func (s *Status) MentionsPopulated() bool { | ||||||
| 	return true | 	return true | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // EmojisPopulated returns whether emojis are populated according to current EmojiIDs. | // EmojisPopulated returns whether emojis are | ||||||
|  | // populated according to current EmojiIDs. | ||||||
| func (s *Status) EmojisPopulated() bool { | func (s *Status) EmojisPopulated() bool { | ||||||
| 	if len(s.EmojiIDs) != len(s.Emojis) { | 	if len(s.EmojiIDs) != len(s.Emojis) { | ||||||
| 		// this is the quickest indicator. | 		// this is the quickest indicator. | ||||||
|  | @ -148,6 +156,21 @@ func (s *Status) EmojisPopulated() bool { | ||||||
| 	return true | 	return true | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // EditsPopulated returns whether edits are | ||||||
|  | // populated according to current EditIDs. | ||||||
|  | func (s *Status) EditsPopulated() bool { | ||||||
|  | 	if len(s.EditIDs) != len(s.Edits) { | ||||||
|  | 		// this is quickest indicator. | ||||||
|  | 		return false | ||||||
|  | 	} | ||||||
|  | 	for i, id := range s.EditIDs { | ||||||
|  | 		if s.Edits[i].ID != id { | ||||||
|  | 			return false | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return true | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // EmojissUpToDate returns whether status emoji attachments of receiving status are up-to-date | // EmojissUpToDate returns whether status emoji attachments of receiving status are up-to-date | ||||||
| // according to emoji attachments of the passed status, by comparing their emoji URIs. We don't | // according to emoji attachments of the passed status, by comparing their emoji URIs. We don't | ||||||
| // use IDs as this is used to determine whether there are new emojis to fetch. | // use IDs as this is used to determine whether there are new emojis to fetch. | ||||||
|  | @ -247,6 +270,35 @@ func (s *Status) IsLocalOnly() bool { | ||||||
| 	return s.Federated == nil || !*s.Federated | 	return s.Federated == nil || !*s.Federated | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // AllAttachmentIDs gathers ALL media attachment IDs from both the | ||||||
|  | // receiving Status{}, and any historical Status{}.Edits. Note that | ||||||
|  | // this function will panic if Status{}.Edits is not populated. | ||||||
|  | func (s *Status) AllAttachmentIDs() []string { | ||||||
|  | 	var total int | ||||||
|  | 
 | ||||||
|  | 	if len(s.EditIDs) != len(s.Edits) { | ||||||
|  | 		panic("status edits not populated") | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Get count of attachment IDs. | ||||||
|  | 	total += len(s.Attachments) | ||||||
|  | 	for _, edit := range s.Edits { | ||||||
|  | 		total += len(edit.AttachmentIDs) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Start gathering of all IDs with *current* attachment IDs. | ||||||
|  | 	attachmentIDs := make([]string, len(s.AttachmentIDs), total) | ||||||
|  | 	copy(attachmentIDs, s.AttachmentIDs) | ||||||
|  | 
 | ||||||
|  | 	// Append IDs of historical edits. | ||||||
|  | 	for _, edit := range s.Edits { | ||||||
|  | 		attachmentIDs = append(attachmentIDs, edit.AttachmentIDs...) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Deduplicate these IDs in case of shared media. | ||||||
|  | 	return xslices.Deduplicate(attachmentIDs) | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // StatusToTag is an intermediate struct to facilitate the many2many relationship between a status and one or more tags. | // StatusToTag is an intermediate struct to facilitate the many2many relationship between a status and one or more tags. | ||||||
| type StatusToTag struct { | type StatusToTag struct { | ||||||
| 	StatusID string  `bun:"type:CHAR(26),unique:statustag,nullzero,notnull"` | 	StatusID string  `bun:"type:CHAR(26),unique:statustag,nullzero,notnull"` | ||||||
|  |  | ||||||
							
								
								
									
										62
									
								
								internal/gtsmodel/statusedit.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										62
									
								
								internal/gtsmodel/statusedit.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,62 @@ | ||||||
|  | // 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 gtsmodel | ||||||
|  | 
 | ||||||
|  | import "time" | ||||||
|  | 
 | ||||||
|  | // StatusEdit represents a **historical** view of a Status | ||||||
|  | // after a received edit. The Status itself will always | ||||||
|  | // contain the latest up-to-date information. | ||||||
|  | // | ||||||
|  | // Note that stored status edits may not exactly match that | ||||||
|  | // of the origin server, they are a best-effort by receiver | ||||||
|  | // to store version history. There is no AP history endpoint. | ||||||
|  | type StatusEdit struct { | ||||||
|  | 	ID                     string             `bun:"type:CHAR(26),pk,nullzero,notnull,unique"`                    // ID of this item in the database. | ||||||
|  | 	Content                string             `bun:""`                                                            // Content of status at time of edit; likely html-formatted but not guaranteed. | ||||||
|  | 	ContentWarning         string             `bun:",nullzero"`                                                   // Content warning of status at time of edit. | ||||||
|  | 	Text                   string             `bun:""`                                                            // Original status text, without formatting, at time of edit. | ||||||
|  | 	Language               string             `bun:",nullzero"`                                                   // Status language at time of edit. | ||||||
|  | 	Sensitive              *bool              `bun:",nullzero,notnull,default:false"`                             // Status sensitive flag at time of edit. | ||||||
|  | 	AttachmentIDs          []string           `bun:"attachments,array"`                                           // Database IDs of media attachments associated with status at time of edit. | ||||||
|  | 	AttachmentDescriptions []string           `bun:",array"`                                                      // Previous media descriptions of media attachments associated with status at time of edit. | ||||||
|  | 	Attachments            []*MediaAttachment `bun:"-"`                                                           // Media attachments relating to .AttachmentIDs field (not always populated). | ||||||
|  | 	PollOptions            []string           `bun:",array"`                                                      // Poll options of status at time of edit, only set if status contains a poll. | ||||||
|  | 	PollVotes              []int              `bun:",array"`                                                      // Poll vote count at time of status edit, only set if poll votes were reset. | ||||||
|  | 	StatusID               string             `bun:"type:CHAR(26),nullzero,notnull"`                              // The originating status ID this is a historical edit of. | ||||||
|  | 	CreatedAt              time.Time          `bun:"type:timestamptz,nullzero,notnull,default:current_timestamp"` // The creation time of this version of the status content (according to receiving server). | ||||||
|  | 
 | ||||||
|  | 	// We don't bother having a *gtsmodel.Status model here | ||||||
|  | 	// as the StatusEdit is always just attached to a Status, | ||||||
|  | 	// so it doesn't need a self-reference back to it. | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // AttachmentsPopulated returns whether media attachments | ||||||
|  | // are populated according to current AttachmentIDs. | ||||||
|  | func (e *StatusEdit) AttachmentsPopulated() bool { | ||||||
|  | 	if len(e.AttachmentIDs) != len(e.Attachments) { | ||||||
|  | 		// this is the quickest indicator. | ||||||
|  | 		return false | ||||||
|  | 	} | ||||||
|  | 	for i, id := range e.AttachmentIDs { | ||||||
|  | 		if e.Attachments[i].ID != id { | ||||||
|  | 			return false | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	return true | ||||||
|  | } | ||||||
|  | @ -22,7 +22,9 @@ import ( | ||||||
| 	"math/big" | 	"math/big" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
|  | 	"codeberg.org/gruf/go-kv" | ||||||
| 	"github.com/oklog/ulid" | 	"github.com/oklog/ulid" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/log" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
|  | @ -45,13 +47,19 @@ func NewULID() string { | ||||||
| 	return ulid.String() | 	return ulid.String() | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // NewULIDFromTime returns a new ULID string using the given time, or an error if something goes wrong. | // NewULIDFromTime returns a new ULID string using | ||||||
| func NewULIDFromTime(t time.Time) (string, error) { | // given time, or from current time on any error. | ||||||
| 	newUlid, err := ulid.New(ulid.Timestamp(t), rand.Reader) | func NewULIDFromTime(t time.Time) string { | ||||||
| 	if err != nil { | 	ts := ulid.Timestamp(t) | ||||||
| 		return "", err | 	if ts > ulid.MaxTime() { | ||||||
|  | 		log.WarnKVs(nil, kv.Fields{ | ||||||
|  | 			{K: "caller", V: log.Caller(2)}, | ||||||
|  | 			{K: "value", V: t}, | ||||||
|  | 			{K: "msg", V: "invalid ulid time"}, | ||||||
|  | 		}...) | ||||||
|  | 		ts = ulid.Now() | ||||||
| 	} | 	} | ||||||
| 	return newUlid.String(), nil | 	return ulid.MustNew(ts, rand.Reader).String() | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // NewRandomULID returns a new ULID string using a random time in an ~80 year range around the current datetime, or an error if something goes wrong. | // NewRandomULID returns a new ULID string using a random time in an ~80 year range around the current datetime, or an error if something goes wrong. | ||||||
|  |  | ||||||
|  | @ -118,15 +118,11 @@ func (m *Manager) CreateMedia( | ||||||
| 		Header:     util.Ptr(false), | 		Header:     util.Ptr(false), | ||||||
| 		Cached:     util.Ptr(false), | 		Cached:     util.Ptr(false), | ||||||
| 		CreatedAt:  now, | 		CreatedAt:  now, | ||||||
| 		UpdatedAt:  now, |  | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Check if we were provided additional info | 	// Check if we were provided additional info | ||||||
| 	// to add to the attachment, and overwrite | 	// to add to the attachment, and overwrite | ||||||
| 	// some of the attachment fields if so. | 	// some of the attachment fields if so. | ||||||
| 	if info.CreatedAt != nil { |  | ||||||
| 		attachment.CreatedAt = *info.CreatedAt |  | ||||||
| 	} |  | ||||||
| 	if info.StatusID != nil { | 	if info.StatusID != nil { | ||||||
| 		attachment.StatusID = *info.StatusID | 		attachment.StatusID = *info.StatusID | ||||||
| 	} | 	} | ||||||
|  | @ -372,9 +368,6 @@ func (m *Manager) createOrUpdateEmoji( | ||||||
| 	if info.URI != nil { | 	if info.URI != nil { | ||||||
| 		emoji.URI = *info.URI | 		emoji.URI = *info.URI | ||||||
| 	} | 	} | ||||||
| 	if info.CreatedAt != nil { |  | ||||||
| 		emoji.CreatedAt = *info.CreatedAt |  | ||||||
| 	} |  | ||||||
| 	if info.Domain != nil { | 	if info.Domain != nil { | ||||||
| 		emoji.Domain = *info.Domain | 		emoji.Domain = *info.Domain | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -109,7 +109,6 @@ func (suite *ManagerTestSuite) TestEmojiProcessRefresh() { | ||||||
| 		emojiToUpdate, | 		emojiToUpdate, | ||||||
| 		data, | 		data, | ||||||
| 		media.AdditionalEmojiInfo{ | 		media.AdditionalEmojiInfo{ | ||||||
| 			CreatedAt:      &emojiToUpdate.CreatedAt, |  | ||||||
| 			Domain:         &emojiToUpdate.Domain, | 			Domain:         &emojiToUpdate.Domain, | ||||||
| 			ImageRemoteURL: &newImageRemoteURL, | 			ImageRemoteURL: &newImageRemoteURL, | ||||||
| 		}, | 		}, | ||||||
|  |  | ||||||
|  | @ -20,7 +20,6 @@ package media | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"io" | 	"io" | ||||||
| 	"time" |  | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type Size string | type Size string | ||||||
|  | @ -44,10 +43,6 @@ const ( | ||||||
| // should be added to attachment when processing a piece of media. | // should be added to attachment when processing a piece of media. | ||||||
| type AdditionalMediaInfo struct { | type AdditionalMediaInfo struct { | ||||||
| 
 | 
 | ||||||
| 	// Time that this media was |  | ||||||
| 	// created; defaults to time.Now(). |  | ||||||
| 	CreatedAt *time.Time |  | ||||||
| 
 |  | ||||||
| 	// ID of the status to which this | 	// ID of the status to which this | ||||||
| 	// media is attached; defaults to "". | 	// media is attached; defaults to "". | ||||||
| 	StatusID *string | 	StatusID *string | ||||||
|  | @ -93,10 +88,6 @@ type AdditionalEmojiInfo struct { | ||||||
| 	// this remote emoji. | 	// this remote emoji. | ||||||
| 	URI *string | 	URI *string | ||||||
| 
 | 
 | ||||||
| 	// Time that this emoji was |  | ||||||
| 	// created; defaults to time.Now(). |  | ||||||
| 	CreatedAt *time.Time |  | ||||||
| 
 |  | ||||||
| 	// Domain the emoji originated from. Blank | 	// Domain the emoji originated from. Blank | ||||||
| 	// for this instance's domain. Defaults to "". | 	// for this instance's domain. Defaults to "". | ||||||
| 	Domain *string | 	Domain *string | ||||||
|  |  | ||||||
|  | @ -70,7 +70,7 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSAdmin() { | ||||||
| func (suite *GetRSSTestSuite) TestGetAccountRSSZork() { | func (suite *GetRSSTestSuite) TestGetAccountRSSZork() { | ||||||
| 	getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(context.Background(), "the_mighty_zork") | 	getFeed, lastModified, err := suite.accountProcessor.GetRSSFeedForUsername(context.Background(), "the_mighty_zork") | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.EqualValues(1704878640, lastModified.Unix()) | 	suite.EqualValues(1730451600, lastModified.Unix()) | ||||||
| 
 | 
 | ||||||
| 	feed, err := getFeed() | 	feed, err := getFeed() | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
|  | @ -79,13 +79,23 @@ func (suite *GetRSSTestSuite) TestGetAccountRSSZork() { | ||||||
|     <title>Posts from @the_mighty_zork@localhost:8080</title> |     <title>Posts from @the_mighty_zork@localhost:8080</title> | ||||||
|     <link>http://localhost:8080/@the_mighty_zork</link> |     <link>http://localhost:8080/@the_mighty_zork</link> | ||||||
|     <description>Posts from @the_mighty_zork@localhost:8080</description> |     <description>Posts from @the_mighty_zork@localhost:8080</description> | ||||||
|     <pubDate>Wed, 10 Jan 2024 09:24:00 +0000</pubDate> |     <pubDate>Fri, 01 Nov 2024 09:00:00 +0000</pubDate> | ||||||
|     <lastBuildDate>Wed, 10 Jan 2024 09:24:00 +0000</lastBuildDate> |     <lastBuildDate>Fri, 01 Nov 2024 09:00:00 +0000</lastBuildDate> | ||||||
|     <image> |     <image> | ||||||
|       <url>http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp</url> |       <url>http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/small/01F8MH58A357CV5K7R7TJMSH6S.webp</url> | ||||||
|       <title>Avatar for @the_mighty_zork@localhost:8080</title> |       <title>Avatar for @the_mighty_zork@localhost:8080</title> | ||||||
|       <link>http://localhost:8080/@the_mighty_zork</link> |       <link>http://localhost:8080/@the_mighty_zork</link> | ||||||
|     </image> |     </image> | ||||||
|  |     <item> | ||||||
|  |       <title>edited status</title> | ||||||
|  |       <link>http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR</link> | ||||||
|  |       <description>@the_mighty_zork@localhost:8080 made a new post: "this is the latest revision of the status, with a content-warning"</description> | ||||||
|  |       <content:encoded><![CDATA[<p>this is the latest revision of the status, with a content-warning</p>]]></content:encoded> | ||||||
|  |       <author>@the_mighty_zork@localhost:8080</author> | ||||||
|  |       <guid isPermaLink="true">http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR</guid> | ||||||
|  |       <pubDate>Fri, 01 Nov 2024 09:00:00 +0000</pubDate> | ||||||
|  |       <source>http://localhost:8080/@the_mighty_zork/feed.rss</source> | ||||||
|  |     </item> | ||||||
|     <item> |     <item> | ||||||
|       <title>HTML in post</title> |       <title>HTML in post</title> | ||||||
|       <link>http://localhost:8080/@the_mighty_zork/statuses/01HH9KYNQPA416TNJ53NSATP40</link> |       <link>http://localhost:8080/@the_mighty_zork/statuses/01HH9KYNQPA416TNJ53NSATP40</link> | ||||||
|  |  | ||||||
|  | @ -177,9 +177,7 @@ func (p *Processor) getAttachmentContent( | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Start preparing API content model. | 	// Start preparing API content model. | ||||||
| 	apiContent := &apimodel.Content{ | 	apiContent := &apimodel.Content{} | ||||||
| 		ContentUpdated: attach.UpdatedAt, |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	// Retrieve appropriate | 	// Retrieve appropriate | ||||||
| 	// size file from storage. | 	// size file from storage. | ||||||
|  |  | ||||||
|  | @ -20,7 +20,6 @@ package media_test | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"testing" | 	"testing" | ||||||
| 	"time" |  | ||||||
| 
 | 
 | ||||||
| 	"github.com/stretchr/testify/suite" | 	"github.com/stretchr/testify/suite" | ||||||
| ) | ) | ||||||
|  | @ -42,8 +41,6 @@ func (suite *UnattachTestSuite) TestUnattachMedia() { | ||||||
| 
 | 
 | ||||||
| 	dbAttachment, errWithCode := suite.db.GetAttachmentByID(ctx, a.ID) | 	dbAttachment, errWithCode := suite.db.GetAttachmentByID(ctx, a.ID) | ||||||
| 	suite.NoError(errWithCode) | 	suite.NoError(errWithCode) | ||||||
| 
 |  | ||||||
| 	suite.WithinDuration(dbAttachment.UpdatedAt, time.Now(), 1*time.Minute) |  | ||||||
| 	suite.Empty(dbAttachment.StatusID) | 	suite.Empty(dbAttachment.StatusID) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -67,7 +67,6 @@ func (p *Processor) Get(ctx context.Context, requestingAccount *gtsmodel.Account | ||||||
| 	if errWithCode != nil { | 	if errWithCode != nil { | ||||||
| 		return nil, errWithCode | 		return nil, errWithCode | ||||||
| 	} | 	} | ||||||
| 
 |  | ||||||
| 	return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus) | 	return p.c.GetAPIStatus(ctx, requestingAccount, targetStatus) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -106,5 +105,6 @@ func (p *Processor) SourceGet(ctx context.Context, requestingAccount *gtsmodel.A | ||||||
| 		err = gtserror.Newf("error converting status: %w", err) | 		err = gtserror.Newf("error converting status: %w", err) | ||||||
| 		return nil, gtserror.NewErrorInternalError(err) | 		return nil, gtserror.NewErrorInternalError(err) | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
| 	return statusSource, nil | 	return statusSource, nil | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -79,8 +79,8 @@ func (suite *NotificationTestSuite) TestStreamNotification() { | ||||||
|     "header_description": "Flat gray background (default header).", |     "header_description": "Flat gray background (default header).", | ||||||
|     "followers_count": 0, |     "followers_count": 0, | ||||||
|     "following_count": 0, |     "following_count": 0, | ||||||
|     "statuses_count": 3, |     "statuses_count": 4, | ||||||
|     "last_status_at": "2021-09-11", |     "last_status_at": "2024-11-01", | ||||||
|     "emojis": [], |     "emojis": [], | ||||||
|     "fields": [] |     "fields": [] | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -54,6 +54,7 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() { | ||||||
| 	suite.Equal(`{ | 	suite.Equal(`{ | ||||||
|   "id": "01FVW7JHQFSFK166WWKR8CBA6M", |   "id": "01FVW7JHQFSFK166WWKR8CBA6M", | ||||||
|   "created_at": "2021-09-20T10:40:37.000Z", |   "created_at": "2021-09-20T10:40:37.000Z", | ||||||
|  |   "edited_at": null, | ||||||
|   "in_reply_to_id": null, |   "in_reply_to_id": null, | ||||||
|   "in_reply_to_account_id": null, |   "in_reply_to_account_id": null, | ||||||
|   "sensitive": false, |   "sensitive": false, | ||||||
|  | @ -90,8 +91,8 @@ func (suite *StatusUpdateTestSuite) TestStreamNotification() { | ||||||
|     "header_description": "Flat gray background (default header).", |     "header_description": "Flat gray background (default header).", | ||||||
|     "followers_count": 0, |     "followers_count": 0, | ||||||
|     "following_count": 0, |     "following_count": 0, | ||||||
|     "statuses_count": 3, |     "statuses_count": 4, | ||||||
|     "last_status_at": "2021-09-11", |     "last_status_at": "2024-11-01", | ||||||
|     "emojis": [], |     "emojis": [], | ||||||
|     "fields": [] |     "fields": [] | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|  | @ -102,8 +102,8 @@ func (suite *PublicTestSuite) TestPublicTimelineGetHideFiltered() { | ||||||
| 		requester           = suite.testAccounts["local_account_1"] | 		requester           = suite.testAccounts["local_account_1"] | ||||||
| 		maxID               = "" | 		maxID               = "" | ||||||
| 		sinceID             = "" | 		sinceID             = "" | ||||||
| 		minID               = "01F8MHAAY43M6RJ473VQFCVH36" // 1 before filteredStatus | 		minID               = "" | ||||||
| 		limit               = 10 | 		limit               = 100 | ||||||
| 		local               = false | 		local               = false | ||||||
| 		filteredStatus      = suite.testStatuses["admin_account_status_2"] | 		filteredStatus      = suite.testStatuses["admin_account_status_2"] | ||||||
| 		filteredStatusFound = false | 		filteredStatusFound = false | ||||||
|  |  | ||||||
|  | @ -75,6 +75,21 @@ func (u *utils) wipeStatus( | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Before handling media, ensure | ||||||
|  | 	// historic edits are populated. | ||||||
|  | 	if !status.EditsPopulated() { | ||||||
|  | 		var err error | ||||||
|  | 
 | ||||||
|  | 		// Fetch all historical edits of status from database. | ||||||
|  | 		status.Edits, err = u.state.DB.GetStatusEditsByIDs( | ||||||
|  | 			gtscontext.SetBarebones(ctx), | ||||||
|  | 			status.EditIDs, | ||||||
|  | 		) | ||||||
|  | 		if err != nil { | ||||||
|  | 			errs.Appendf("error getting status edits from database: %w", err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	// Either delete all attachments for this status, | 	// Either delete all attachments for this status, | ||||||
| 	// or simply detach + clean them separately later. | 	// or simply detach + clean them separately later. | ||||||
| 	// | 	// | ||||||
|  | @ -83,20 +98,27 @@ func (u *utils) wipeStatus( | ||||||
| 	// status immediately (in case of delete + redraft). | 	// status immediately (in case of delete + redraft). | ||||||
| 	if deleteAttachments { | 	if deleteAttachments { | ||||||
| 		// todo:u.state.DB.DeleteAttachmentsForStatus | 		// todo:u.state.DB.DeleteAttachmentsForStatus | ||||||
| 		for _, id := range status.AttachmentIDs { | 		for _, id := range status.AllAttachmentIDs() { | ||||||
| 			if err := u.media.Delete(ctx, id); err != nil { | 			if err := u.media.Delete(ctx, id); err != nil { | ||||||
| 				errs.Appendf("error deleting media: %w", err) | 				errs.Appendf("error deleting media: %w", err) | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} else { | 	} else { | ||||||
| 		// todo:u.state.DB.UnattachAttachmentsForStatus | 		// todo:u.state.DB.UnattachAttachmentsForStatus | ||||||
| 		for _, id := range status.AttachmentIDs { | 		for _, id := range status.AllAttachmentIDs() { | ||||||
| 			if _, err := u.media.Unattach(ctx, status.Account, id); err != nil { | 			if _, err := u.media.Unattach(ctx, status.Account, id); err != nil { | ||||||
| 				errs.Appendf("error unattaching media: %w", err) | 				errs.Appendf("error unattaching media: %w", err) | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Delete all historical edits of status. | ||||||
|  | 	if ids := status.EditIDs; len(ids) > 0 { | ||||||
|  | 		if err := u.state.DB.DeleteStatusEdits(ctx, ids); err != nil { | ||||||
|  | 			errs.Appendf("error deleting status edits: %w", err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	// Delete all mentions generated by this status. | 	// Delete all mentions generated by this status. | ||||||
| 	// todo:u.state.DB.DeleteMentionsForStatus | 	// todo:u.state.DB.DeleteMentionsForStatus | ||||||
| 	for _, id := range status.MentionIDs { | 	for _, id := range status.MentionIDs { | ||||||
|  | @ -120,19 +142,20 @@ func (u *utils) wipeStatus( | ||||||
| 		errs.Appendf("error deleting status faves: %w", err) | 		errs.Appendf("error deleting status faves: %w", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if pollID := status.PollID; pollID != "" { | 	if id := status.PollID; id != "" { | ||||||
| 		// Delete this poll by ID from the database. | 		// Delete this poll by ID from the database. | ||||||
| 		if err := u.state.DB.DeletePollByID(ctx, pollID); err != nil { | 		if err := u.state.DB.DeletePollByID(ctx, id); err != nil { | ||||||
| 			errs.Appendf("error deleting status poll: %w", err) | 			errs.Appendf("error deleting status poll: %w", err) | ||||||
| 		} | 		} | ||||||
| 
 | 
 | ||||||
| 		// Cancel any scheduled expiry task for poll. | 		// Cancel any scheduled expiry task for poll. | ||||||
| 		_ = u.state.Workers.Scheduler.Cancel(pollID) | 		_ = u.state.Workers.Scheduler.Cancel(id) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Get all boost of this status so that we can | 	// Get all boost of this status so that we can | ||||||
| 	// delete those boosts + remove them from timelines. | 	// delete those boosts + remove them from timelines. | ||||||
| 	boosts, err := u.state.DB.GetStatusBoosts( | 	boosts, err := u.state.DB.GetStatusBoosts( | ||||||
|  | 
 | ||||||
| 		// We MUST set a barebones context here, | 		// We MUST set a barebones context here, | ||||||
| 		// as depending on where it came from the | 		// as depending on where it came from the | ||||||
| 		// original BoostOf may already be gone. | 		// original BoostOf may already be gone. | ||||||
|  | @ -537,11 +560,7 @@ func (u *utils) requestFave( | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Create + store new interaction request. | 	// Create + store new interaction request. | ||||||
| 	req, err = typeutils.StatusFaveToInteractionRequest(ctx, fave) | 	req = typeutils.StatusFaveToInteractionRequest(fave) | ||||||
| 	if err != nil { |  | ||||||
| 		return gtserror.Newf("error creating interaction request: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil { | 	if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil { | ||||||
| 		return gtserror.Newf("db error storing interaction request: %w", err) | 		return gtserror.Newf("db error storing interaction request: %w", err) | ||||||
| 	} | 	} | ||||||
|  | @ -584,11 +603,7 @@ func (u *utils) requestReply( | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Create + store interaction request. | 	// Create + store interaction request. | ||||||
| 	req, err = typeutils.StatusToInteractionRequest(ctx, reply) | 	req = typeutils.StatusToInteractionRequest(reply) | ||||||
| 	if err != nil { |  | ||||||
| 		return gtserror.Newf("error creating interaction request: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil { | 	if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil { | ||||||
| 		return gtserror.Newf("db error storing interaction request: %w", err) | 		return gtserror.Newf("db error storing interaction request: %w", err) | ||||||
| 	} | 	} | ||||||
|  | @ -631,11 +646,7 @@ func (u *utils) requestAnnounce( | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Create + store interaction request. | 	// Create + store interaction request. | ||||||
| 	req, err = typeutils.StatusToInteractionRequest(ctx, boost) | 	req = typeutils.StatusToInteractionRequest(boost) | ||||||
| 	if err != nil { |  | ||||||
| 		return gtserror.Newf("error creating interaction request: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil { | 	if err := u.state.DB.PutInteractionRequest(ctx, req); err != nil { | ||||||
| 		return gtserror.Newf("db error storing interaction request: %w", err) | 		return gtserror.Newf("db error storing interaction request: %w", err) | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
|  | @ -228,7 +228,7 @@ func (suite *GetTestSuite) TestGetNewTimelineMoreThanPossible() { | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		suite.FailNow(err.Error()) | 		suite.FailNow(err.Error()) | ||||||
| 	} | 	} | ||||||
| 	suite.checkStatuses(statuses, id.Highest, id.Lowest, 20) | 	suite.checkStatuses(statuses, id.Highest, id.Lowest, 22) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *GetTestSuite) TestGetNewTimelineMoreThanPossiblePageUp() { | func (suite *GetTestSuite) TestGetNewTimelineMoreThanPossiblePageUp() { | ||||||
|  | @ -255,7 +255,7 @@ func (suite *GetTestSuite) TestGetNewTimelineMoreThanPossiblePageUp() { | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		suite.FailNow(err.Error()) | 		suite.FailNow(err.Error()) | ||||||
| 	} | 	} | ||||||
| 	suite.checkStatuses(statuses, id.Highest, id.Lowest, 20) | 	suite.checkStatuses(statuses, id.Highest, id.Lowest, 22) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (suite *GetTestSuite) TestGetNewTimelineNoFollowing() { | func (suite *GetTestSuite) TestGetNewTimelineNoFollowing() { | ||||||
|  | @ -284,7 +284,7 @@ func (suite *GetTestSuite) TestGetNewTimelineNoFollowing() { | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		suite.FailNow(err.Error()) | 		suite.FailNow(err.Error()) | ||||||
| 	} | 	} | ||||||
| 	suite.checkStatuses(statuses, id.Highest, id.Lowest, 8) | 	suite.checkStatuses(statuses, id.Highest, id.Lowest, 9) | ||||||
| 
 | 
 | ||||||
| 	for _, s := range statuses { | 	for _, s := range statuses { | ||||||
| 		if s.GetAccountID() != testAccount.ID { | 		if s.GetAccountID() != testAccount.ID { | ||||||
|  |  | ||||||
|  | @ -40,7 +40,7 @@ func (suite *PruneTestSuite) TestPrune() { | ||||||
| 
 | 
 | ||||||
| 	pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) | 	pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.Equal(20, pruned) | 	suite.Equal(23, pruned) | ||||||
| 	suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) | 	suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -56,7 +56,7 @@ func (suite *PruneTestSuite) TestPruneTwice() { | ||||||
| 
 | 
 | ||||||
| 	pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) | 	pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.Equal(20, pruned) | 	suite.Equal(23, pruned) | ||||||
| 	suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) | 	suite.Equal(5, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) | ||||||
| 
 | 
 | ||||||
| 	// Prune same again, nothing should be pruned this time. | 	// Prune same again, nothing should be pruned this time. | ||||||
|  | @ -78,7 +78,7 @@ func (suite *PruneTestSuite) TestPruneTo0() { | ||||||
| 
 | 
 | ||||||
| 	pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) | 	pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.Equal(25, pruned) | 	suite.Equal(28, pruned) | ||||||
| 	suite.Equal(0, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) | 	suite.Equal(0, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -95,7 +95,7 @@ func (suite *PruneTestSuite) TestPruneToInfinityAndBeyond() { | ||||||
| 	pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) | 	pruned, err := suite.state.Timelines.Home.Prune(ctx, testAccountID, desiredPreparedItemsLength, desiredIndexedItemsLength) | ||||||
| 	suite.NoError(err) | 	suite.NoError(err) | ||||||
| 	suite.Equal(0, pruned) | 	suite.Equal(0, pruned) | ||||||
| 	suite.Equal(25, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) | 	suite.Equal(28, suite.state.Timelines.Home.GetIndexedLength(ctx, testAccountID)) | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestPruneTestSuite(t *testing.T) { | func TestPruneTestSuite(t *testing.T) { | ||||||
|  |  | ||||||
|  | @ -111,6 +111,13 @@ func (c *Converter) ASRepresentationToAccount( | ||||||
| 		acct.UpdatedAt = pub | 		acct.UpdatedAt = pub | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Extract updated time if possible, i.e. last edited. | ||||||
|  | 	if upd := ap.GetUpdated(accountable); !upd.IsZero() { | ||||||
|  | 		acct.UpdatedAt = upd | ||||||
|  | 	} else { | ||||||
|  | 		acct.UpdatedAt = acct.CreatedAt | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	// Extract a preferred name (display name), fallback to username. | 	// Extract a preferred name (display name), fallback to username. | ||||||
| 	if displayName := ap.ExtractName(accountable); displayName != "" { | 	if displayName := ap.ExtractName(accountable); displayName != "" { | ||||||
| 		acct.DisplayName = displayName | 		acct.DisplayName = displayName | ||||||
|  | @ -348,18 +355,25 @@ func (c *Converter) ASStatusToStatus(ctx context.Context, statusable ap.Statusab | ||||||
| 	// zero-time will fall back to db defaults. | 	// zero-time will fall back to db defaults. | ||||||
| 	if pub := ap.GetPublished(statusable); !pub.IsZero() { | 	if pub := ap.GetPublished(statusable); !pub.IsZero() { | ||||||
| 		status.CreatedAt = pub | 		status.CreatedAt = pub | ||||||
| 		status.UpdatedAt = pub |  | ||||||
| 	} else { | 	} else { | ||||||
| 		log.Warnf(ctx, "unusable published property on %s", uri) | 		log.Warnf(ctx, "unusable published property on %s", uri) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// status.Updated | ||||||
|  | 	// | ||||||
|  | 	// Extract updated time for status, defaults to Published. | ||||||
|  | 	if upd := ap.GetUpdated(statusable); !upd.IsZero() { | ||||||
|  | 		status.UpdatedAt = upd | ||||||
|  | 	} else { | ||||||
|  | 		status.UpdatedAt = status.CreatedAt | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	// status.AccountURI | 	// status.AccountURI | ||||||
| 	// status.AccountID | 	// status.AccountID | ||||||
| 	// status.Account | 	// status.Account | ||||||
| 	// | 	// | ||||||
| 	// Account that created the status. Assume we have | 	// Account that created the status. Assume we have this | ||||||
| 	// this in the db by the time this function is called, | 	// in the db by the time this function is called, else error. | ||||||
| 	// error if we don't. |  | ||||||
| 	status.Account, err = c.getASAttributedToAccount(ctx, | 	status.Account, err = c.getASAttributedToAccount(ctx, | ||||||
| 		status.URI, | 		status.URI, | ||||||
| 		statusable, | 		statusable, | ||||||
|  |  | ||||||
|  | @ -104,14 +104,8 @@ func (c *Converter) StatusToBoost( | ||||||
| 	return boost, nil | 	return boost, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func StatusToInteractionRequest( | func StatusToInteractionRequest(status *gtsmodel.Status) *gtsmodel.InteractionRequest { | ||||||
| 	ctx context.Context, | 	reqID := id.NewULIDFromTime(status.CreatedAt) | ||||||
| 	status *gtsmodel.Status, |  | ||||||
| ) (*gtsmodel.InteractionRequest, error) { |  | ||||||
| 	reqID, err := id.NewULIDFromTime(status.CreatedAt) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, gtserror.Newf("error generating ID: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	var ( | 	var ( | ||||||
| 		targetID        string | 		targetID        string | ||||||
|  | @ -154,17 +148,11 @@ func StatusToInteractionRequest( | ||||||
| 		InteractionType:      interactionType, | 		InteractionType:      interactionType, | ||||||
| 		Reply:                reply, | 		Reply:                reply, | ||||||
| 		Announce:             announce, | 		Announce:             announce, | ||||||
| 	}, nil | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func StatusFaveToInteractionRequest( | func StatusFaveToInteractionRequest(fave *gtsmodel.StatusFave) *gtsmodel.InteractionRequest { | ||||||
| 	ctx context.Context, | 	reqID := id.NewULIDFromTime(fave.CreatedAt) | ||||||
| 	fave *gtsmodel.StatusFave, |  | ||||||
| ) (*gtsmodel.InteractionRequest, error) { |  | ||||||
| 	reqID, err := id.NewULIDFromTime(fave.CreatedAt) |  | ||||||
| 	if err != nil { |  | ||||||
| 		return nil, gtserror.Newf("error generating ID: %w", err) |  | ||||||
| 	} |  | ||||||
| 
 | 
 | ||||||
| 	return >smodel.InteractionRequest{ | 	return >smodel.InteractionRequest{ | ||||||
| 		ID:                   reqID, | 		ID:                   reqID, | ||||||
|  | @ -178,7 +166,7 @@ func StatusFaveToInteractionRequest( | ||||||
| 		InteractionURI:       fave.URI, | 		InteractionURI:       fave.URI, | ||||||
| 		InteractionType:      gtsmodel.InteractionLike, | 		InteractionType:      gtsmodel.InteractionLike, | ||||||
| 		Like:                 fave, | 		Like:                 fave, | ||||||
| 	}, nil | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (c *Converter) StatusToSinBinStatus( | func (c *Converter) StatusToSinBinStatus( | ||||||
|  |  | ||||||
|  | @ -484,10 +484,9 @@ func (c *Converter) StatusToAS(ctx context.Context, s *gtsmodel.Status) (ap.Stat | ||||||
| 		status.SetActivityStreamsInReplyTo(inReplyToProp) | 		status.SetActivityStreamsInReplyTo(inReplyToProp) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// published | 	// Set created / updated at properties. | ||||||
| 	publishedProp := streams.NewActivityStreamsPublishedProperty() | 	ap.SetPublished(status, s.CreatedAt) | ||||||
| 	publishedProp.Set(s.CreatedAt) | 	ap.SetUpdated(status, s.UpdatedAt) | ||||||
| 	status.SetActivityStreamsPublished(publishedProp) |  | ||||||
| 
 | 
 | ||||||
| 	// url | 	// url | ||||||
| 	if s.URL != "" { | 	if s.URL != "" { | ||||||
|  |  | ||||||
|  | @ -499,6 +499,7 @@ func (suite *InternalToASTestSuite) TestStatusToAS() { | ||||||
|   "tag": [], |   "tag": [], | ||||||
|   "to": "https://www.w3.org/ns/activitystreams#Public", |   "to": "https://www.w3.org/ns/activitystreams#Public", | ||||||
|   "type": "Note", |   "type": "Note", | ||||||
|  |   "updated": "2021-10-20T12:40:37+02:00", | ||||||
|   "url": "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY" |   "url": "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY" | ||||||
| }`, string(bytes)) | }`, string(bytes)) | ||||||
| } | } | ||||||
|  | @ -598,6 +599,7 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASWithIDs() { | ||||||
|   ], |   ], | ||||||
|   "to": "https://www.w3.org/ns/activitystreams#Public", |   "to": "https://www.w3.org/ns/activitystreams#Public", | ||||||
|   "type": "Note", |   "type": "Note", | ||||||
|  |   "updated": "2021-10-20T11:36:45Z", | ||||||
|   "url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R" |   "url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R" | ||||||
| }`, string(bytes)) | }`, string(bytes)) | ||||||
| } | } | ||||||
|  | @ -698,6 +700,7 @@ func (suite *InternalToASTestSuite) TestStatusWithTagsToASFromDB() { | ||||||
|   ], |   ], | ||||||
|   "to": "https://www.w3.org/ns/activitystreams#Public", |   "to": "https://www.w3.org/ns/activitystreams#Public", | ||||||
|   "type": "Note", |   "type": "Note", | ||||||
|  |   "updated": "2021-10-20T11:36:45Z", | ||||||
|   "url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R" |   "url": "http://localhost:8080/@admin/statuses/01F8MH75CBF9JFX4ZAD54N0W0R" | ||||||
| }`, string(bytes)) | }`, string(bytes)) | ||||||
| } | } | ||||||
|  | @ -778,6 +781,7 @@ func (suite *InternalToASTestSuite) TestStatusToASWithMentions() { | ||||||
|   }, |   }, | ||||||
|   "to": "https://www.w3.org/ns/activitystreams#Public", |   "to": "https://www.w3.org/ns/activitystreams#Public", | ||||||
|   "type": "Note", |   "type": "Note", | ||||||
|  |   "updated": "2021-11-20T13:32:16Z", | ||||||
|   "url": "http://localhost:8080/@admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0" |   "url": "http://localhost:8080/@admin/statuses/01FF25D5Q0DH7CHD57CTRS6WK0" | ||||||
| }`, string(bytes)) | }`, string(bytes)) | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -1399,17 +1399,13 @@ func (c *Converter) baseStatusToFrontend( | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// Nullable fields. | 	// Nullable fields. | ||||||
| 	if s.InReplyToID != "" { | 	if !s.UpdatedAt.Equal(s.CreatedAt) { | ||||||
| 		apiStatus.InReplyToID = util.Ptr(s.InReplyToID) | 		timestamp := util.FormatISO8601(s.UpdatedAt) | ||||||
| 	} | 		apiStatus.EditedAt = util.Ptr(timestamp) | ||||||
| 
 |  | ||||||
| 	if s.InReplyToAccountID != "" { |  | ||||||
| 		apiStatus.InReplyToAccountID = util.Ptr(s.InReplyToAccountID) |  | ||||||
| 	} |  | ||||||
| 
 |  | ||||||
| 	if s.Language != "" { |  | ||||||
| 		apiStatus.Language = util.Ptr(s.Language) |  | ||||||
| 	} | 	} | ||||||
|  | 	apiStatus.InReplyToID = util.PtrIf(s.InReplyToID) | ||||||
|  | 	apiStatus.InReplyToAccountID = util.PtrIf(s.InReplyToAccountID) | ||||||
|  | 	apiStatus.Language = util.PtrIf(s.Language) | ||||||
| 
 | 
 | ||||||
| 	if app := s.CreatedWithApplication; app != nil { | 	if app := s.CreatedWithApplication; app != nil { | ||||||
| 		apiStatus.Application, err = c.AppToAPIAppPublic(ctx, app) | 		apiStatus.Application, err = c.AppToAPIAppPublic(ctx, app) | ||||||
|  |  | ||||||
|  | @ -67,8 +67,8 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontend() { | ||||||
|   "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |   "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|   "followers_count": 2, |   "followers_count": 2, | ||||||
|   "following_count": 2, |   "following_count": 2, | ||||||
|   "statuses_count": 8, |   "statuses_count": 9, | ||||||
|   "last_status_at": "2024-01-10", |   "last_status_at": "2024-11-01", | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "fields": [], |   "fields": [], | ||||||
|   "enable_rss": true |   "enable_rss": true | ||||||
|  | @ -119,8 +119,8 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendAliasedAndMoved() | ||||||
|   "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |   "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|   "followers_count": 2, |   "followers_count": 2, | ||||||
|   "following_count": 2, |   "following_count": 2, | ||||||
|   "statuses_count": 8, |   "statuses_count": 9, | ||||||
|   "last_status_at": "2024-01-10", |   "last_status_at": "2024-11-01", | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "fields": [], |   "fields": [], | ||||||
|   "source": { |   "source": { | ||||||
|  | @ -162,8 +162,8 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendAliasedAndMoved() | ||||||
|     "header_description": "Flat gray background (default header).", |     "header_description": "Flat gray background (default header).", | ||||||
|     "followers_count": 1, |     "followers_count": 1, | ||||||
|     "following_count": 1, |     "following_count": 1, | ||||||
|     "statuses_count": 8, |     "statuses_count": 9, | ||||||
|     "last_status_at": "2021-07-28", |     "last_status_at": "2024-11-01", | ||||||
|     "emojis": [], |     "emojis": [], | ||||||
|     "fields": [ |     "fields": [ | ||||||
|       { |       { | ||||||
|  | @ -217,8 +217,8 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiStruct() | ||||||
|   "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |   "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|   "followers_count": 2, |   "followers_count": 2, | ||||||
|   "following_count": 2, |   "following_count": 2, | ||||||
|   "statuses_count": 8, |   "statuses_count": 9, | ||||||
|   "last_status_at": "2024-01-10", |   "last_status_at": "2024-11-01", | ||||||
|   "emojis": [ |   "emojis": [ | ||||||
|     { |     { | ||||||
|       "shortcode": "rainbow", |       "shortcode": "rainbow", | ||||||
|  | @ -266,8 +266,8 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendWithEmojiIDs() { | ||||||
|   "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |   "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|   "followers_count": 2, |   "followers_count": 2, | ||||||
|   "following_count": 2, |   "following_count": 2, | ||||||
|   "statuses_count": 8, |   "statuses_count": 9, | ||||||
|   "last_status_at": "2024-01-10", |   "last_status_at": "2024-11-01", | ||||||
|   "emojis": [ |   "emojis": [ | ||||||
|     { |     { | ||||||
|       "shortcode": "rainbow", |       "shortcode": "rainbow", | ||||||
|  | @ -311,8 +311,8 @@ func (suite *InternalToFrontendTestSuite) TestAccountToFrontendSensitive() { | ||||||
|   "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |   "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|   "followers_count": 2, |   "followers_count": 2, | ||||||
|   "following_count": 2, |   "following_count": 2, | ||||||
|   "statuses_count": 8, |   "statuses_count": 9, | ||||||
|   "last_status_at": "2024-01-10", |   "last_status_at": "2024-11-01", | ||||||
|   "emojis": [], |   "emojis": [], | ||||||
|   "fields": [], |   "fields": [], | ||||||
|   "source": { |   "source": { | ||||||
|  | @ -463,6 +463,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontend() { | ||||||
| 	suite.Equal(`{ | 	suite.Equal(`{ | ||||||
|   "id": "01F8MH75CBF9JFX4ZAD54N0W0R", |   "id": "01F8MH75CBF9JFX4ZAD54N0W0R", | ||||||
|   "created_at": "2021-10-20T11:36:45.000Z", |   "created_at": "2021-10-20T11:36:45.000Z", | ||||||
|  |   "edited_at": null, | ||||||
|   "in_reply_to_id": null, |   "in_reply_to_id": null, | ||||||
|   "in_reply_to_account_id": null, |   "in_reply_to_account_id": null, | ||||||
|   "sensitive": false, |   "sensitive": false, | ||||||
|  | @ -641,6 +642,7 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredStatusToFrontend() { | ||||||
| 	suite.Equal(`{ | 	suite.Equal(`{ | ||||||
|   "id": "01F8MH75CBF9JFX4ZAD54N0W0R", |   "id": "01F8MH75CBF9JFX4ZAD54N0W0R", | ||||||
|   "created_at": "2021-10-20T11:36:45.000Z", |   "created_at": "2021-10-20T11:36:45.000Z", | ||||||
|  |   "edited_at": null, | ||||||
|   "in_reply_to_id": null, |   "in_reply_to_id": null, | ||||||
|   "in_reply_to_account_id": null, |   "in_reply_to_account_id": null, | ||||||
|   "sensitive": false, |   "sensitive": false, | ||||||
|  | @ -807,6 +809,7 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredBoostToFrontend() { | ||||||
| 	suite.Equal(`{ | 	suite.Equal(`{ | ||||||
|   "id": "01G36SF3V6Y6V5BF9P4R7PQG7G", |   "id": "01G36SF3V6Y6V5BF9P4R7PQG7G", | ||||||
|   "created_at": "2021-10-20T10:41:37.000Z", |   "created_at": "2021-10-20T10:41:37.000Z", | ||||||
|  |   "edited_at": null, | ||||||
|   "in_reply_to_id": null, |   "in_reply_to_id": null, | ||||||
|   "in_reply_to_account_id": null, |   "in_reply_to_account_id": null, | ||||||
|   "sensitive": false, |   "sensitive": false, | ||||||
|  | @ -827,6 +830,7 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredBoostToFrontend() { | ||||||
|   "reblog": { |   "reblog": { | ||||||
|     "id": "01F8MH75CBF9JFX4ZAD54N0W0R", |     "id": "01F8MH75CBF9JFX4ZAD54N0W0R", | ||||||
|     "created_at": "2021-10-20T11:36:45.000Z", |     "created_at": "2021-10-20T11:36:45.000Z", | ||||||
|  |     "edited_at": null, | ||||||
|     "in_reply_to_id": null, |     "in_reply_to_id": null, | ||||||
|     "in_reply_to_account_id": null, |     "in_reply_to_account_id": null, | ||||||
|     "sensitive": false, |     "sensitive": false, | ||||||
|  | @ -870,8 +874,8 @@ func (suite *InternalToFrontendTestSuite) TestWarnFilteredBoostToFrontend() { | ||||||
|       "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |       "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|       "followers_count": 2, |       "followers_count": 2, | ||||||
|       "following_count": 2, |       "following_count": 2, | ||||||
|       "statuses_count": 8, |       "statuses_count": 9, | ||||||
|       "last_status_at": "2024-01-10", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [], |       "fields": [], | ||||||
|       "enable_rss": true |       "enable_rss": true | ||||||
|  | @ -1218,6 +1222,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownAttachments | ||||||
| 	suite.Equal(`{ | 	suite.Equal(`{ | ||||||
|   "id": "01HE7XJ1CG84TBKH5V9XKBVGF5", |   "id": "01HE7XJ1CG84TBKH5V9XKBVGF5", | ||||||
|   "created_at": "2023-11-02T10:44:25.000Z", |   "created_at": "2023-11-02T10:44:25.000Z", | ||||||
|  |   "edited_at": null, | ||||||
|   "in_reply_to_id": "01F8MH75CBF9JFX4ZAD54N0W0R", |   "in_reply_to_id": "01F8MH75CBF9JFX4ZAD54N0W0R", | ||||||
|   "in_reply_to_account_id": "01F8MH17FWEB39HZJ76B6VXSKF", |   "in_reply_to_account_id": "01F8MH17FWEB39HZJ76B6VXSKF", | ||||||
|   "sensitive": true, |   "sensitive": true, | ||||||
|  | @ -1350,6 +1355,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToWebStatus() { | ||||||
| 	suite.Equal(`{ | 	suite.Equal(`{ | ||||||
|   "id": "01HE7XJ1CG84TBKH5V9XKBVGF5", |   "id": "01HE7XJ1CG84TBKH5V9XKBVGF5", | ||||||
|   "created_at": "2023-11-02T10:44:25.000Z", |   "created_at": "2023-11-02T10:44:25.000Z", | ||||||
|  |   "edited_at": null, | ||||||
|   "in_reply_to_id": "01F8MH75CBF9JFX4ZAD54N0W0R", |   "in_reply_to_id": "01F8MH75CBF9JFX4ZAD54N0W0R", | ||||||
|   "in_reply_to_account_id": "01F8MH17FWEB39HZJ76B6VXSKF", |   "in_reply_to_account_id": "01F8MH17FWEB39HZJ76B6VXSKF", | ||||||
|   "sensitive": true, |   "sensitive": true, | ||||||
|  | @ -1511,6 +1517,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendUnknownLanguage() | ||||||
| 	suite.Equal(`{ | 	suite.Equal(`{ | ||||||
|   "id": "01F8MH75CBF9JFX4ZAD54N0W0R", |   "id": "01F8MH75CBF9JFX4ZAD54N0W0R", | ||||||
|   "created_at": "2021-10-20T11:36:45.000Z", |   "created_at": "2021-10-20T11:36:45.000Z", | ||||||
|  |   "edited_at": null, | ||||||
|   "in_reply_to_id": null, |   "in_reply_to_id": null, | ||||||
|   "in_reply_to_account_id": null, |   "in_reply_to_account_id": null, | ||||||
|   "sensitive": false, |   "sensitive": false, | ||||||
|  | @ -1654,6 +1661,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteraction | ||||||
| 	suite.Equal(`{ | 	suite.Equal(`{ | ||||||
|   "id": "01F8MHBBN8120SYH7D5S050MGK", |   "id": "01F8MHBBN8120SYH7D5S050MGK", | ||||||
|   "created_at": "2021-10-20T10:40:37.000Z", |   "created_at": "2021-10-20T10:40:37.000Z", | ||||||
|  |   "edited_at": null, | ||||||
|   "in_reply_to_id": null, |   "in_reply_to_id": null, | ||||||
|   "in_reply_to_account_id": null, |   "in_reply_to_account_id": null, | ||||||
|   "sensitive": false, |   "sensitive": false, | ||||||
|  | @ -1697,8 +1705,8 @@ func (suite *InternalToFrontendTestSuite) TestStatusToFrontendPartialInteraction | ||||||
|     "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |     "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|     "followers_count": 2, |     "followers_count": 2, | ||||||
|     "following_count": 2, |     "following_count": 2, | ||||||
|     "statuses_count": 8, |     "statuses_count": 9, | ||||||
|     "last_status_at": "2024-01-10", |     "last_status_at": "2024-11-01", | ||||||
|     "emojis": [], |     "emojis": [], | ||||||
|     "fields": [], |     "fields": [], | ||||||
|     "enable_rss": true |     "enable_rss": true | ||||||
|  | @ -1764,6 +1772,7 @@ func (suite *InternalToFrontendTestSuite) TestStatusToAPIStatusPendingApproval() | ||||||
| 	suite.Equal(`{ | 	suite.Equal(`{ | ||||||
|   "id": "01J5QVB9VC76NPPRQ207GG4DRZ", |   "id": "01J5QVB9VC76NPPRQ207GG4DRZ", | ||||||
|   "created_at": "2024-02-20T10:41:37.000Z", |   "created_at": "2024-02-20T10:41:37.000Z", | ||||||
|  |   "edited_at": null, | ||||||
|   "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5", |   "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5", | ||||||
|   "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF", |   "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|   "sensitive": false, |   "sensitive": false, | ||||||
|  | @ -1993,7 +2002,7 @@ func (suite *InternalToFrontendTestSuite) TestInstanceV1ToFrontend() { | ||||||
|   }, |   }, | ||||||
|   "stats": { |   "stats": { | ||||||
|     "domain_count": 2, |     "domain_count": 2, | ||||||
|     "status_count": 19, |     "status_count": 21, | ||||||
|     "user_count": 4 |     "user_count": 4 | ||||||
|   }, |   }, | ||||||
|   "thumbnail": "http://localhost:8080/assets/logo.webp", |   "thumbnail": "http://localhost:8080/assets/logo.webp", | ||||||
|  | @ -2277,8 +2286,8 @@ func (suite *InternalToFrontendTestSuite) TestReportToFrontend1() { | ||||||
|     "header_description": "Flat gray background (default header).", |     "header_description": "Flat gray background (default header).", | ||||||
|     "followers_count": 0, |     "followers_count": 0, | ||||||
|     "following_count": 0, |     "following_count": 0, | ||||||
|     "statuses_count": 3, |     "statuses_count": 4, | ||||||
|     "last_status_at": "2021-09-11", |     "last_status_at": "2024-11-01", | ||||||
|     "emojis": [], |     "emojis": [], | ||||||
|     "fields": [] |     "fields": [] | ||||||
|   } |   } | ||||||
|  | @ -2321,8 +2330,8 @@ func (suite *InternalToFrontendTestSuite) TestReportToFrontend2() { | ||||||
|     "header_description": "Flat gray background (default header).", |     "header_description": "Flat gray background (default header).", | ||||||
|     "followers_count": 1, |     "followers_count": 1, | ||||||
|     "following_count": 1, |     "following_count": 1, | ||||||
|     "statuses_count": 8, |     "statuses_count": 9, | ||||||
|     "last_status_at": "2021-07-28", |     "last_status_at": "2024-11-01", | ||||||
|     "emojis": [], |     "emojis": [], | ||||||
|     "fields": [ |     "fields": [ | ||||||
|       { |       { | ||||||
|  | @ -2398,8 +2407,8 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend1() { | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 0, |       "followers_count": 0, | ||||||
|       "following_count": 0, |       "following_count": 0, | ||||||
|       "statuses_count": 3, |       "statuses_count": 4, | ||||||
|       "last_status_at": "2021-09-11", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [] |       "fields": [] | ||||||
|     } |     } | ||||||
|  | @ -2444,8 +2453,8 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend1() { | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 1, |       "followers_count": 1, | ||||||
|       "following_count": 1, |       "following_count": 1, | ||||||
|       "statuses_count": 8, |       "statuses_count": 9, | ||||||
|       "last_status_at": "2021-07-28", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [ |       "fields": [ | ||||||
|         { |         { | ||||||
|  | @ -2636,8 +2645,8 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend2() { | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 1, |       "followers_count": 1, | ||||||
|       "following_count": 1, |       "following_count": 1, | ||||||
|       "statuses_count": 8, |       "statuses_count": 9, | ||||||
|       "last_status_at": "2021-07-28", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [ |       "fields": [ | ||||||
|         { |         { | ||||||
|  | @ -2695,8 +2704,8 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend2() { | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 0, |       "followers_count": 0, | ||||||
|       "following_count": 0, |       "following_count": 0, | ||||||
|       "statuses_count": 3, |       "statuses_count": 4, | ||||||
|       "last_status_at": "2021-09-11", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [] |       "fields": [] | ||||||
|     } |     } | ||||||
|  | @ -2707,6 +2716,7 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend2() { | ||||||
|     { |     { | ||||||
|       "id": "01FVW7JHQFSFK166WWKR8CBA6M", |       "id": "01FVW7JHQFSFK166WWKR8CBA6M", | ||||||
|       "created_at": "2021-09-20T10:40:37.000Z", |       "created_at": "2021-09-20T10:40:37.000Z", | ||||||
|  |       "edited_at": null, | ||||||
|       "in_reply_to_id": null, |       "in_reply_to_id": null, | ||||||
|       "in_reply_to_account_id": null, |       "in_reply_to_account_id": null, | ||||||
|       "sensitive": false, |       "sensitive": false, | ||||||
|  | @ -2743,8 +2753,8 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontend2() { | ||||||
|         "header_description": "Flat gray background (default header).", |         "header_description": "Flat gray background (default header).", | ||||||
|         "followers_count": 0, |         "followers_count": 0, | ||||||
|         "following_count": 0, |         "following_count": 0, | ||||||
|         "statuses_count": 3, |         "statuses_count": 4, | ||||||
|         "last_status_at": "2021-09-11", |         "last_status_at": "2024-11-01", | ||||||
|         "emojis": [], |         "emojis": [], | ||||||
|         "fields": [] |         "fields": [] | ||||||
|       }, |       }, | ||||||
|  | @ -2902,8 +2912,8 @@ func (suite *InternalToFrontendTestSuite) TestAdminReportToFrontendSuspendedLoca | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 0, |       "followers_count": 0, | ||||||
|       "following_count": 0, |       "following_count": 0, | ||||||
|       "statuses_count": 3, |       "statuses_count": 4, | ||||||
|       "last_status_at": "2021-09-11", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [] |       "fields": [] | ||||||
|     } |     } | ||||||
|  | @ -3214,6 +3224,7 @@ func (suite *InternalToFrontendTestSuite) TestIntReqToAPI() { | ||||||
|   "status": { |   "status": { | ||||||
|     "id": "01F8MHC8VWDRBQR0N1BATDDEM5", |     "id": "01F8MHC8VWDRBQR0N1BATDDEM5", | ||||||
|     "created_at": "2021-10-20T10:40:37.000Z", |     "created_at": "2021-10-20T10:40:37.000Z", | ||||||
|  |     "edited_at": null, | ||||||
|     "in_reply_to_id": null, |     "in_reply_to_id": null, | ||||||
|     "in_reply_to_account_id": null, |     "in_reply_to_account_id": null, | ||||||
|     "sensitive": true, |     "sensitive": true, | ||||||
|  | @ -3254,8 +3265,8 @@ func (suite *InternalToFrontendTestSuite) TestIntReqToAPI() { | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 1, |       "followers_count": 1, | ||||||
|       "following_count": 1, |       "following_count": 1, | ||||||
|       "statuses_count": 8, |       "statuses_count": 9, | ||||||
|       "last_status_at": "2021-07-28", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [ |       "fields": [ | ||||||
|         { |         { | ||||||
|  | @ -3307,6 +3318,7 @@ func (suite *InternalToFrontendTestSuite) TestIntReqToAPI() { | ||||||
|   "reply": { |   "reply": { | ||||||
|     "id": "01J5QVB9VC76NPPRQ207GG4DRZ", |     "id": "01J5QVB9VC76NPPRQ207GG4DRZ", | ||||||
|     "created_at": "2024-02-20T10:41:37.000Z", |     "created_at": "2024-02-20T10:41:37.000Z", | ||||||
|  |     "edited_at": null, | ||||||
|     "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5", |     "in_reply_to_id": "01F8MHC8VWDRBQR0N1BATDDEM5", | ||||||
|     "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF", |     "in_reply_to_account_id": "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|     "sensitive": false, |     "sensitive": false, | ||||||
|  | @ -3464,8 +3476,8 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPISelfConvo() { | ||||||
|       "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |       "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|       "followers_count": 2, |       "followers_count": 2, | ||||||
|       "following_count": 2, |       "following_count": 2, | ||||||
|       "statuses_count": 8, |       "statuses_count": 9, | ||||||
|       "last_status_at": "2024-01-10", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [], |       "fields": [], | ||||||
|       "enable_rss": true |       "enable_rss": true | ||||||
|  | @ -3474,6 +3486,7 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPISelfConvo() { | ||||||
|   "last_status": { |   "last_status": { | ||||||
|     "id": "01F8MHAMCHF6Y650WCRSCP4WMY", |     "id": "01F8MHAMCHF6Y650WCRSCP4WMY", | ||||||
|     "created_at": "2021-10-20T10:40:37.000Z", |     "created_at": "2021-10-20T10:40:37.000Z", | ||||||
|  |     "edited_at": null, | ||||||
|     "in_reply_to_id": null, |     "in_reply_to_id": null, | ||||||
|     "in_reply_to_account_id": null, |     "in_reply_to_account_id": null, | ||||||
|     "sensitive": true, |     "sensitive": true, | ||||||
|  | @ -3517,8 +3530,8 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPISelfConvo() { | ||||||
|       "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |       "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|       "followers_count": 2, |       "followers_count": 2, | ||||||
|       "following_count": 2, |       "following_count": 2, | ||||||
|       "statuses_count": 8, |       "statuses_count": 9, | ||||||
|       "last_status_at": "2024-01-10", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [], |       "fields": [], | ||||||
|       "enable_rss": true |       "enable_rss": true | ||||||
|  | @ -3619,8 +3632,8 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPI() { | ||||||
|       "header_description": "Flat gray background (default header).", |       "header_description": "Flat gray background (default header).", | ||||||
|       "followers_count": 1, |       "followers_count": 1, | ||||||
|       "following_count": 1, |       "following_count": 1, | ||||||
|       "statuses_count": 8, |       "statuses_count": 9, | ||||||
|       "last_status_at": "2021-07-28", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [ |       "fields": [ | ||||||
|         { |         { | ||||||
|  | @ -3640,6 +3653,7 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPI() { | ||||||
|   "last_status": { |   "last_status": { | ||||||
|     "id": "01F8MHAMCHF6Y650WCRSCP4WMY", |     "id": "01F8MHAMCHF6Y650WCRSCP4WMY", | ||||||
|     "created_at": "2021-10-20T10:40:37.000Z", |     "created_at": "2021-10-20T10:40:37.000Z", | ||||||
|  |     "edited_at": null, | ||||||
|     "in_reply_to_id": null, |     "in_reply_to_id": null, | ||||||
|     "in_reply_to_account_id": null, |     "in_reply_to_account_id": null, | ||||||
|     "sensitive": true, |     "sensitive": true, | ||||||
|  | @ -3683,8 +3697,8 @@ func (suite *InternalToFrontendTestSuite) TestConversationToAPI() { | ||||||
|       "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", |       "header_media_id": "01PFPMWK2FF0D9WMHEJHR07C3Q", | ||||||
|       "followers_count": 2, |       "followers_count": 2, | ||||||
|       "following_count": 2, |       "following_count": 2, | ||||||
|       "statuses_count": 8, |       "statuses_count": 9, | ||||||
|       "last_status_at": "2024-01-10", |       "last_status_at": "2024-11-01", | ||||||
|       "emojis": [], |       "emojis": [], | ||||||
|       "fields": [], |       "fields": [], | ||||||
|       "enable_rss": true |       "enable_rss": true | ||||||
|  |  | ||||||
|  | @ -131,6 +131,7 @@ func (suite *WrapTestSuite) TestWrapNoteInCreate() { | ||||||
|     "tag": [], |     "tag": [], | ||||||
|     "to": "https://www.w3.org/ns/activitystreams#Public", |     "to": "https://www.w3.org/ns/activitystreams#Public", | ||||||
|     "type": "Note", |     "type": "Note", | ||||||
|  |     "updated": "2021-10-20T12:40:37+02:00", | ||||||
|     "url": "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY" |     "url": "http://localhost:8080/@the_mighty_zork/statuses/01F8MHAMCHF6Y650WCRSCP4WMY" | ||||||
|   }, |   }, | ||||||
|   "published": "2021-10-20T12:40:37+02:00", |   "published": "2021-10-20T12:40:37+02:00", | ||||||
|  |  | ||||||
|  | @ -64,6 +64,7 @@ EXPECT=$(cat << "EOF" | ||||||
|         "sin-bin-status-mem-ratio": 0.5, |         "sin-bin-status-mem-ratio": 0.5, | ||||||
|         "status-bookmark-ids-mem-ratio": 2, |         "status-bookmark-ids-mem-ratio": 2, | ||||||
|         "status-bookmark-mem-ratio": 0.5, |         "status-bookmark-mem-ratio": 0.5, | ||||||
|  |         "status-edit-mem-ratio": 2, | ||||||
|         "status-fave-ids-mem-ratio": 3, |         "status-fave-ids-mem-ratio": 3, | ||||||
|         "status-fave-mem-ratio": 2, |         "status-fave-mem-ratio": 2, | ||||||
|         "status-mem-ratio": 5, |         "status-mem-ratio": 5, | ||||||
|  |  | ||||||
|  | @ -52,6 +52,7 @@ var testModels = []interface{}{ | ||||||
| 	>smodel.Status{}, | 	>smodel.Status{}, | ||||||
| 	>smodel.StatusToEmoji{}, | 	>smodel.StatusToEmoji{}, | ||||||
| 	>smodel.StatusToTag{}, | 	>smodel.StatusToTag{}, | ||||||
|  | 	>smodel.StatusEdit{}, | ||||||
| 	>smodel.StatusFave{}, | 	>smodel.StatusFave{}, | ||||||
| 	>smodel.StatusBookmark{}, | 	>smodel.StatusBookmark{}, | ||||||
| 	>smodel.Tag{}, | 	>smodel.Tag{}, | ||||||
|  | @ -101,7 +102,7 @@ func CreateTestTables(db db.DB) { | ||||||
| 	ctx := context.Background() | 	ctx := context.Background() | ||||||
| 	for _, m := range testModels { | 	for _, m := range testModels { | ||||||
| 		if err := db.CreateTable(ctx, m); err != nil { | 		if err := db.CreateTable(ctx, m); err != nil { | ||||||
| 			log.Panicf(nil, "error creating table for %+v: %s", m, err) | 			log.Panicf(ctx, "error creating table for %+v: %s", m, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | @ -125,243 +126,249 @@ func StandardDBSetup(db db.DB, accounts map[string]*gtsmodel.Account) { | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestTokens() { | 	for _, v := range NewTestTokens() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestClients() { | 	for _, v := range NewTestClients() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestApplications() { | 	for _, v := range NewTestApplications() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestBlocks() { | 	for _, v := range NewTestBlocks() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestReports() { | 	for _, v := range NewTestReports() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestRules() { | 	for _, v := range NewTestRules() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestDomainBlocks() { | 	for _, v := range NewTestDomainBlocks() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestInstances() { | 	for _, v := range NewTestInstances() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestUsers() { | 	for _, v := range NewTestUsers() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if accounts == nil { | 	if accounts == nil { | ||||||
| 		for _, v := range NewTestAccounts() { | 		for _, v := range NewTestAccounts() { | ||||||
| 			if err := db.Put(ctx, v); err != nil { | 			if err := db.Put(ctx, v); err != nil { | ||||||
| 				log.Panic(nil, err) | 				log.Panic(ctx, err) | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} else { | 	} else { | ||||||
| 		for _, v := range accounts { | 		for _, v := range accounts { | ||||||
| 			if err := db.Put(ctx, v); err != nil { | 			if err := db.Put(ctx, v); err != nil { | ||||||
| 				log.Panic(nil, err) | 				log.Panic(ctx, err) | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestAccountSettings() { | 	for _, v := range NewTestAccountSettings() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestAttachments() { | 	for _, v := range NewTestAttachments() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestStatuses() { | 	for _, v := range NewTestStatuses() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestEmojis() { | 	for _, v := range NewTestEmojis() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestEmojiCategories() { | 	for _, v := range NewTestEmojiCategories() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestStatusToEmojis() { | 	for _, v := range NewTestStatusToEmojis() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestTags() { | 	for _, v := range NewTestTags() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestStatusToTags() { | 	for _, v := range NewTestStatusToTags() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestMentions() { | 	for _, v := range NewTestMentions() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestFaves() { | 	for _, v := range NewTestFaves() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestFollows() { | 	for _, v := range NewTestFollows() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestLists() { | 	for _, v := range NewTestLists() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestListEntries() { | 	for _, v := range NewTestListEntries() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestNotifications() { | 	for _, v := range NewTestNotifications() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestTombstones() { | 	for _, v := range NewTestTombstones() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestBookmarks() { | 	for _, v := range NewTestBookmarks() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestAccountNotes() { | 	for _, v := range NewTestAccountNotes() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestMarkers() { | 	for _, v := range NewTestMarkers() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestThreads() { | 	for _, v := range NewTestThreads() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestThreadToStatus() { | 	for _, v := range NewTestThreadToStatus() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestPolls() { | 	for _, v := range NewTestPolls() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestPollVotes() { | 	for _, v := range NewTestPollVotes() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestFilters() { | 	for _, v := range NewTestFilters() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestFilterKeywords() { | 	for _, v := range NewTestFilterKeywords() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestFilterStatuses() { | 	for _, v := range NewTestFilterStatuses() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestUserMutes() { | 	for _, v := range NewTestUserMutes() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	for _, v := range NewTestInteractionRequests() { | 	for _, v := range NewTestInteractionRequests() { | ||||||
| 		if err := db.Put(ctx, v); err != nil { | 		if err := db.Put(ctx, v); err != nil { | ||||||
| 			log.Panic(nil, err) | 			log.Panic(ctx, err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	for _, v := range NewTestStatusEdits() { | ||||||
|  | 		if err := db.Put(ctx, v); err != nil { | ||||||
|  | 			log.Panic(ctx, err) | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if err := db.CreateInstanceAccount(ctx); err != nil { | 	if err := db.CreateInstanceAccount(ctx); err != nil { | ||||||
| 		log.Panic(nil, err) | 		log.Panic(ctx, err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	if err := db.CreateInstanceInstance(ctx); err != nil { | 	if err := db.CreateInstanceInstance(ctx); err != nil { | ||||||
| 		log.Panic(nil, err) | 		log.Panic(ctx, err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	log.Debug(nil, "testing db setup complete") | 	log.Debug(ctx, "testing db setup complete") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // StandardDBTeardown drops all the standard testing tables/models from the database to ensure it's clean for the next test. | // StandardDBTeardown drops all the standard testing tables/models from the database to ensure it's clean for the next test. | ||||||
|  |  | ||||||
|  | @ -718,7 +718,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			URL:       "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", | 			URL:       "http://localhost:8080/fileserver/01F8MH17FWEB39HZJ76B6VXSKF/attachment/original/01F8MH6NEM8D7527KZAECTCR76.jpg", | ||||||
| 			RemoteURL: "", | 			RemoteURL: "", | ||||||
| 			CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"), | 			CreatedAt: TimeMustParse("2022-06-04T13:12:00Z"), | ||||||
| 			UpdatedAt: TimeMustParse("2022-06-04T13:12:00Z"), |  | ||||||
| 			Type:      gtsmodel.FileTypeImage, | 			Type:      gtsmodel.FileTypeImage, | ||||||
| 			FileMeta: gtsmodel.FileMeta{ | 			FileMeta: gtsmodel.FileMeta{ | ||||||
| 				Original: gtsmodel.Original{ | 				Original: gtsmodel.Original{ | ||||||
|  | @ -761,7 +760,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			URL:       "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH7TDVANYKWVE8VVKFPJTJ.gif", | 			URL:       "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH7TDVANYKWVE8VVKFPJTJ.gif", | ||||||
| 			RemoteURL: "", | 			RemoteURL: "", | ||||||
| 			CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), | 			CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), | ||||||
| 			UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), |  | ||||||
| 			Type:      gtsmodel.FileTypeImage, | 			Type:      gtsmodel.FileTypeImage, | ||||||
| 			FileMeta: gtsmodel.FileMeta{ | 			FileMeta: gtsmodel.FileMeta{ | ||||||
| 				Original: gtsmodel.Original{ | 				Original: gtsmodel.Original{ | ||||||
|  | @ -808,7 +806,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			URL:       "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01CDR64G398ADCHXK08WWTHEZ5.mp4", | 			URL:       "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01CDR64G398ADCHXK08WWTHEZ5.mp4", | ||||||
| 			RemoteURL: "", | 			RemoteURL: "", | ||||||
| 			CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), | 			CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), | ||||||
| 			UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), |  | ||||||
| 			Type:      gtsmodel.FileTypeVideo, | 			Type:      gtsmodel.FileTypeVideo, | ||||||
| 			FileMeta: gtsmodel.FileMeta{ | 			FileMeta: gtsmodel.FileMeta{ | ||||||
| 				Original: gtsmodel.Original{ | 				Original: gtsmodel.Original{ | ||||||
|  | @ -858,7 +855,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			URL:       "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpg", | 			URL:       "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01F8MH8RMYQ6MSNY3JM2XT1CQ5.jpg", | ||||||
| 			RemoteURL: "", | 			RemoteURL: "", | ||||||
| 			CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), | 			CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), | ||||||
| 			UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), |  | ||||||
| 			Type:      gtsmodel.FileTypeImage, | 			Type:      gtsmodel.FileTypeImage, | ||||||
| 			FileMeta: gtsmodel.FileMeta{ | 			FileMeta: gtsmodel.FileMeta{ | ||||||
| 				Original: gtsmodel.Original{ | 				Original: gtsmodel.Original{ | ||||||
|  | @ -905,7 +901,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			URL:       "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", | 			URL:       "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/avatar/original/01F8MH58A357CV5K7R7TJMSH6S.jpg", | ||||||
| 			RemoteURL: "", | 			RemoteURL: "", | ||||||
| 			CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), | 			CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), | ||||||
| 			UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), |  | ||||||
| 			Type:      gtsmodel.FileTypeImage, | 			Type:      gtsmodel.FileTypeImage, | ||||||
| 			FileMeta: gtsmodel.FileMeta{ | 			FileMeta: gtsmodel.FileMeta{ | ||||||
| 				Original: gtsmodel.Original{ | 				Original: gtsmodel.Original{ | ||||||
|  | @ -952,7 +947,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			URL:       "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", | 			URL:       "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/header/original/01PFPMWK2FF0D9WMHEJHR07C3Q.jpg", | ||||||
| 			RemoteURL: "", | 			RemoteURL: "", | ||||||
| 			CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), | 			CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), | ||||||
| 			UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), |  | ||||||
| 			Type:      gtsmodel.FileTypeImage, | 			Type:      gtsmodel.FileTypeImage, | ||||||
| 			FileMeta: gtsmodel.FileMeta{ | 			FileMeta: gtsmodel.FileMeta{ | ||||||
| 				Original: gtsmodel.Original{ | 				Original: gtsmodel.Original{ | ||||||
|  | @ -999,7 +993,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			URL:       "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01J2M20K6K9XQC4WSB961YJHV6.mp3", | 			URL:       "http://localhost:8080/fileserver/01F8MH1H7YV1Z7D2C8K2730QBF/attachment/original/01J2M20K6K9XQC4WSB961YJHV6.mp3", | ||||||
| 			RemoteURL: "", | 			RemoteURL: "", | ||||||
| 			CreatedAt: TimeMustParse("2024-01-10T11:24:00+02:00"), | 			CreatedAt: TimeMustParse("2024-01-10T11:24:00+02:00"), | ||||||
| 			UpdatedAt: TimeMustParse("2024-01-10T11:24:00+02:00"), |  | ||||||
| 			Type:      gtsmodel.FileTypeAudio, | 			Type:      gtsmodel.FileTypeAudio, | ||||||
| 			FileMeta: gtsmodel.FileMeta{ | 			FileMeta: gtsmodel.FileMeta{ | ||||||
| 				Original: gtsmodel.Original{ | 				Original: gtsmodel.Original{ | ||||||
|  | @ -1043,13 +1036,30 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			Header: util.Ptr(false), | 			Header: util.Ptr(false), | ||||||
| 			Cached: util.Ptr(true), | 			Cached: util.Ptr(true), | ||||||
| 		}, | 		}, | ||||||
|  | 		"local_account_2_status_9_attachment_1": { | ||||||
|  | 			ID:          "01JDQ164HM08SGJ7ZEK9003Z4B", | ||||||
|  | 			StatusID:    "01JDPZEZ77X1NX0TY9M10BK1HM", | ||||||
|  | 			URL:         "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3", | ||||||
|  | 			RemoteURL:   "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3", | ||||||
|  | 			CreatedAt:   TimeMustParse("2024-11-01T10:01:00+02:00"), | ||||||
|  | 			Type:        gtsmodel.FileTypeUnknown, | ||||||
|  | 			FileMeta:    gtsmodel.FileMeta{}, | ||||||
|  | 			AccountID:   "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|  | 			Description: "Jolly salsa song, public domain.", | ||||||
|  | 			Blurhash:    "", | ||||||
|  | 			Processing:  gtsmodel.ProcessingStatusProcessed, | ||||||
|  | 			File:        gtsmodel.File{}, | ||||||
|  | 			Thumbnail:   gtsmodel.Thumbnail{RemoteURL: ""}, | ||||||
|  | 			Avatar:      util.Ptr(false), | ||||||
|  | 			Header:      util.Ptr(false), | ||||||
|  | 			Cached:      util.Ptr(false), | ||||||
|  | 		}, | ||||||
| 		"remote_account_1_status_1_attachment_1": { | 		"remote_account_1_status_1_attachment_1": { | ||||||
| 			ID:        "01FVW7RXPQ8YJHTEXYPE7Q8ZY0", | 			ID:        "01FVW7RXPQ8YJHTEXYPE7Q8ZY0", | ||||||
| 			StatusID:  "01FVW7JHQFSFK166WWKR8CBA6M", | 			StatusID:  "01FVW7JHQFSFK166WWKR8CBA6M", | ||||||
| 			URL:       "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", | 			URL:       "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", | ||||||
| 			RemoteURL: "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpg", | 			RemoteURL: "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpg", | ||||||
| 			CreatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), | 			CreatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), | ||||||
| 			UpdatedAt: TimeMustParse("2021-09-20T12:40:37+02:00"), |  | ||||||
| 			Type:      gtsmodel.FileTypeImage, | 			Type:      gtsmodel.FileTypeImage, | ||||||
| 			FileMeta: gtsmodel.FileMeta{ | 			FileMeta: gtsmodel.FileMeta{ | ||||||
| 				Original: gtsmodel.Original{ | 				Original: gtsmodel.Original{ | ||||||
|  | @ -1095,7 +1105,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			URL:       "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", | 			URL:       "http://localhost:8080/fileserver/062G5WYKY35KKD12EMSM3F8PJ8/header/original/01PFPMWK2FF0D9WMHEJHR07C3R.jpg", | ||||||
| 			RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg", | 			RemoteURL: "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg", | ||||||
| 			CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), | 			CreatedAt: TimeMustParse("2022-06-09T13:12:00Z"), | ||||||
| 			UpdatedAt: TimeMustParse("2022-06-09T13:12:00Z"), |  | ||||||
| 			Type:      gtsmodel.FileTypeImage, | 			Type:      gtsmodel.FileTypeImage, | ||||||
| 			FileMeta: gtsmodel.FileMeta{ | 			FileMeta: gtsmodel.FileMeta{ | ||||||
| 				Original: gtsmodel.Original{ | 				Original: gtsmodel.Original{ | ||||||
|  | @ -1141,7 +1150,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			URL:       "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7Y3C432WRSNS10EZM86SA5.jpg", | 			URL:       "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7Y3C432WRSNS10EZM86SA5.jpg", | ||||||
| 			RemoteURL: "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7Y6G0EMCKST3Q0914WW0MS.jpg", | 			RemoteURL: "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7Y6G0EMCKST3Q0914WW0MS.jpg", | ||||||
| 			CreatedAt: TimeMustParse("2023-11-02T12:44:25+02:00"), | 			CreatedAt: TimeMustParse("2023-11-02T12:44:25+02:00"), | ||||||
| 			UpdatedAt: TimeMustParse("2023-11-02T12:44:25+02:00"), |  | ||||||
| 			Type:      gtsmodel.FileTypeImage, | 			Type:      gtsmodel.FileTypeImage, | ||||||
| 			FileMeta: gtsmodel.FileMeta{ | 			FileMeta: gtsmodel.FileMeta{ | ||||||
| 				Original: gtsmodel.Original{ | 				Original: gtsmodel.Original{ | ||||||
|  | @ -1186,7 +1194,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			URL:         "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg", | 			URL:         "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE7ZFX9GKA5ZZVD4FACABSS9.svg", | ||||||
| 			RemoteURL:   "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg", | 			RemoteURL:   "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE7ZGJYTSYMXF927GF9353KR.svg", | ||||||
| 			CreatedAt:   TimeMustParse("2023-11-02T12:44:25+02:00"), | 			CreatedAt:   TimeMustParse("2023-11-02T12:44:25+02:00"), | ||||||
| 			UpdatedAt:   TimeMustParse("2023-11-02T12:44:25+02:00"), |  | ||||||
| 			Type:        gtsmodel.FileTypeUnknown, | 			Type:        gtsmodel.FileTypeUnknown, | ||||||
| 			FileMeta:    gtsmodel.FileMeta{}, | 			FileMeta:    gtsmodel.FileMeta{}, | ||||||
| 			AccountID:   "01FHMQX3GAABWSM0S2VZEC2SWC", | 			AccountID:   "01FHMQX3GAABWSM0S2VZEC2SWC", | ||||||
|  | @ -1205,7 +1212,6 @@ func NewTestAttachments() map[string]*gtsmodel.MediaAttachment { | ||||||
| 			URL:         "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3", | 			URL:         "http://localhost:8080/fileserver/01FHMQX3GAABWSM0S2VZEC2SWC/attachment/original/01HE88YG74PVAB81PX2XA9F3FG.mp3", | ||||||
| 			RemoteURL:   "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3", | 			RemoteURL:   "http://example.org/fileserver/01HE7Y659ZWZ02JM4AWYJZ176Q/attachment/original/01HE892Y8ZS68TQCNPX7J888P3.mp3", | ||||||
| 			CreatedAt:   TimeMustParse("2023-11-02T12:44:25+02:00"), | 			CreatedAt:   TimeMustParse("2023-11-02T12:44:25+02:00"), | ||||||
| 			UpdatedAt:   TimeMustParse("2023-11-02T12:44:25+02:00"), |  | ||||||
| 			Type:        gtsmodel.FileTypeUnknown, | 			Type:        gtsmodel.FileTypeUnknown, | ||||||
| 			FileMeta:    gtsmodel.FileMeta{}, | 			FileMeta:    gtsmodel.FileMeta{}, | ||||||
| 			AccountID:   "01FHMQX3GAABWSM0S2VZEC2SWC", | 			AccountID:   "01FHMQX3GAABWSM0S2VZEC2SWC", | ||||||
|  | @ -1739,6 +1745,32 @@ func NewTestStatuses() map[string]*gtsmodel.Status { | ||||||
| 			Federated:                util.Ptr(true), | 			Federated:                util.Ptr(true), | ||||||
| 			ActivityStreamsType:      ap.ObjectNote, | 			ActivityStreamsType:      ap.ObjectNote, | ||||||
| 		}, | 		}, | ||||||
|  | 		"local_account_1_status_9": { | ||||||
|  | 			ID:                       "01JDPZC707CKDN8N4QVWM4Z1NR", | ||||||
|  | 			URI:                      "http://localhost:8080/users/the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR", | ||||||
|  | 			URL:                      "http://localhost:8080/@the_mighty_zork/statuses/01JDPZC707CKDN8N4QVWM4Z1NR", | ||||||
|  | 			Content:                  "<p>this is the latest revision of the status, with a content-warning</p>", | ||||||
|  | 			Text:                     "this is the latest revision of the status, with a content-warning", | ||||||
|  | 			ContentWarning:           "edited status", | ||||||
|  | 			AttachmentIDs:            nil, | ||||||
|  | 			CreatedAt:                TimeMustParse("2024-11-01T11:00:00+02:00"), | ||||||
|  | 			UpdatedAt:                TimeMustParse("2024-11-01T11:02:00+02:00"), | ||||||
|  | 			Local:                    util.Ptr(true), | ||||||
|  | 			AccountURI:               "http://localhost:8080/users/the_mighty_zork", | ||||||
|  | 			AccountID:                "01F8MH1H7YV1Z7D2C8K2730QBF", | ||||||
|  | 			InReplyToID:              "", | ||||||
|  | 			InReplyToAccountID:       "", | ||||||
|  | 			InReplyToURI:             "", | ||||||
|  | 			BoostOfID:                "", | ||||||
|  | 			ThreadID:                 "", | ||||||
|  | 			EditIDs:                  []string{"01JDPZCZ2Y9KSGZW0R7ZG8T8Y2", "01JDPZDADMD1T9HKF94RECF7PP"}, | ||||||
|  | 			Visibility:               gtsmodel.VisibilityPublic, | ||||||
|  | 			Sensitive:                util.Ptr(false), | ||||||
|  | 			Language:                 "en", | ||||||
|  | 			CreatedWithApplicationID: "01F8MGY43H3N2C8EWPR2FPYEXG", | ||||||
|  | 			Federated:                util.Ptr(true), | ||||||
|  | 			ActivityStreamsType:      ap.ObjectNote, | ||||||
|  | 		}, | ||||||
| 		"local_account_2_status_1": { | 		"local_account_2_status_1": { | ||||||
| 			ID:                       "01F8MHBQCBTDKN6X5VHGMMN4MA", | 			ID:                       "01F8MHBQCBTDKN6X5VHGMMN4MA", | ||||||
| 			URI:                      "http://localhost:8080/users/1happyturtle/statuses/01F8MHBQCBTDKN6X5VHGMMN4MA", | 			URI:                      "http://localhost:8080/users/1happyturtle/statuses/01F8MHBQCBTDKN6X5VHGMMN4MA", | ||||||
|  | @ -1967,6 +1999,32 @@ func NewTestStatuses() map[string]*gtsmodel.Status { | ||||||
| 			PollID:                   "01HEN2QB5NR4NCEHGYC3HN84K6", | 			PollID:                   "01HEN2QB5NR4NCEHGYC3HN84K6", | ||||||
| 			PendingApproval:          util.Ptr(false), | 			PendingApproval:          util.Ptr(false), | ||||||
| 		}, | 		}, | ||||||
|  | 		"local_account_2_status_9": { | ||||||
|  | 			ID:                       "01JDPZEZ77X1NX0TY9M10BK1HM", | ||||||
|  | 			URI:                      "http://localhost:8080/users/1happyturtle/statuses/01JDPZEZ77X1NX0TY9M10BK1HM", | ||||||
|  | 			URL:                      "http://localhost:8080/@1happyturtle/statuses/01JDPZEZ77X1NX0TY9M10BK1HM", | ||||||
|  | 			Content:                  "<p>now edited to bring back the previous edit's media!</p>", | ||||||
|  | 			Text:                     "now edited to bring back the previous edit's media!", | ||||||
|  | 			ContentWarning:           "edit with media attachments", | ||||||
|  | 			AttachmentIDs:            []string{"01JDQ164HM08SGJ7ZEK9003Z4B"}, | ||||||
|  | 			CreatedAt:                TimeMustParse("2024-11-01T10:00:00+02:00"), | ||||||
|  | 			UpdatedAt:                TimeMustParse("2024-11-01T10:03:00+02:00"), | ||||||
|  | 			Local:                    util.Ptr(true), | ||||||
|  | 			AccountURI:               "http://localhost:8080/users/the_mighty_zork", | ||||||
|  | 			AccountID:                "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|  | 			InReplyToID:              "", | ||||||
|  | 			InReplyToAccountID:       "", | ||||||
|  | 			InReplyToURI:             "", | ||||||
|  | 			BoostOfID:                "", | ||||||
|  | 			ThreadID:                 "", | ||||||
|  | 			EditIDs:                  []string{"01JDPZPBXAX0M02YSEPB21KX4R", "01JDPZPJHKP7E3M0YQXEXPS1YT", "01JDPZPY3F85Y7B78ETRXEMWD9"}, | ||||||
|  | 			Visibility:               gtsmodel.VisibilityPublic, | ||||||
|  | 			Sensitive:                util.Ptr(false), | ||||||
|  | 			Language:                 "en", | ||||||
|  | 			CreatedWithApplicationID: "01F8MGYG9E893WRHW0TAEXR8GJ", | ||||||
|  | 			Federated:                util.Ptr(true), | ||||||
|  | 			ActivityStreamsType:      ap.ObjectNote, | ||||||
|  | 		}, | ||||||
| 		"remote_account_1_status_1": { | 		"remote_account_1_status_1": { | ||||||
| 			ID:                       "01FVW7JHQFSFK166WWKR8CBA6M", | 			ID:                       "01FVW7JHQFSFK166WWKR8CBA6M", | ||||||
| 			URI:                      "http://fossbros-anonymous.io/users/foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M", | 			URI:                      "http://fossbros-anonymous.io/users/foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M", | ||||||
|  | @ -2042,6 +2100,33 @@ func NewTestStatuses() map[string]*gtsmodel.Status { | ||||||
| 			PollID:                   "01HEWV1GW2D49R919NPEDXPTZ5", | 			PollID:                   "01HEWV1GW2D49R919NPEDXPTZ5", | ||||||
| 			PendingApproval:          util.Ptr(false), | 			PendingApproval:          util.Ptr(false), | ||||||
| 		}, | 		}, | ||||||
|  | 		"remote_account_1_status_4": { | ||||||
|  | 			ID:                       "01JDQ07JZTX9CMDJP67CNA71YD", | ||||||
|  | 			URI:                      "http://fossbros-anonymous.io/users/foss_satan/statuses/______", | ||||||
|  | 			URL:                      "http://fossbros-anonymous.io/@foss_satan/statuses/______", | ||||||
|  | 			Content:                  "<p>this is the latest status edit without poll change</p>", | ||||||
|  | 			Text:                     "this is the latest status edit without poll change", | ||||||
|  | 			ContentWarning:           "", | ||||||
|  | 			AttachmentIDs:            nil, | ||||||
|  | 			CreatedAt:                TimeMustParse("2024-11-01T09:00:00+02:00"), | ||||||
|  | 			UpdatedAt:                TimeMustParse("2024-11-01T09:02:00+02:00"), | ||||||
|  | 			Local:                    util.Ptr(false), | ||||||
|  | 			AccountURI:               "http://fossbros-anonymous.io/users/foss_satan", | ||||||
|  | 			AccountID:                "01F8MH5ZK5VRH73AKHQM6Y9VNX", | ||||||
|  | 			InReplyToID:              "", | ||||||
|  | 			InReplyToAccountID:       "", | ||||||
|  | 			InReplyToURI:             "", | ||||||
|  | 			BoostOfID:                "", | ||||||
|  | 			ThreadID:                 "", | ||||||
|  | 			EditIDs:                  []string{"01JDQ07ZZ4FGP13YN8TF63P5A6", "01JDQ08AYQC0G6413VAHA51CV9"}, | ||||||
|  | 			PollID:                   "01JDQ0EZ5HM9T4WXRQ5WSVD40J", | ||||||
|  | 			Visibility:               gtsmodel.VisibilityPublic, | ||||||
|  | 			Sensitive:                util.Ptr(false), | ||||||
|  | 			Language:                 "en", | ||||||
|  | 			CreatedWithApplicationID: "01F8MGYG9E893WRHW0TAEXR8GJ", | ||||||
|  | 			Federated:                util.Ptr(true), | ||||||
|  | 			ActivityStreamsType:      ap.ObjectNote, | ||||||
|  | 		}, | ||||||
| 		"remote_account_2_status_1": { | 		"remote_account_2_status_1": { | ||||||
| 			ID:                       "01HE7XJ1CG84TBKH5V9XKBVGF5", | 			ID:                       "01HE7XJ1CG84TBKH5V9XKBVGF5", | ||||||
| 			URI:                      "http://example.org/users/Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5", | 			URI:                      "http://example.org/users/Some_User/statuses/01HE7XJ1CG84TBKH5V9XKBVGF5", | ||||||
|  | @ -2125,6 +2210,19 @@ func NewTestPolls() map[string]*gtsmodel.Poll { | ||||||
| 			ClosedAt:  time.Time{}, | 			ClosedAt:  time.Time{}, | ||||||
| 			Closing:   false, | 			Closing:   false, | ||||||
| 		}, | 		}, | ||||||
|  | 		"remote_account_1_status_4_poll": { | ||||||
|  | 			ID:         "01JDQ0EZ5HM9T4WXRQ5WSVD40J", | ||||||
|  | 			Multiple:   util.Ptr(false), | ||||||
|  | 			HideCounts: util.Ptr(false), | ||||||
|  | 			Options:    []string{"yes", "no", "maybe", "i don't know", "can you repeat the question"}, | ||||||
|  | 			Votes:      []int{0, 0, 0, 0, 2}, | ||||||
|  | 			Voters:     util.Ptr(2), | ||||||
|  | 			StatusID:   "01JDQ07JZTX9CMDJP67CNA71YD", | ||||||
|  | 			// empty expiry AND closed date, i.e. no end | ||||||
|  | 			ExpiresAt: time.Time{}, | ||||||
|  | 			ClosedAt:  time.Time{}, | ||||||
|  | 			Closing:   false, | ||||||
|  | 		}, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -2184,6 +2282,24 @@ func NewTestPollVotes() map[string]*gtsmodel.PollVote { | ||||||
| 			Poll:      nil, | 			Poll:      nil, | ||||||
| 			CreatedAt: TimeMustParse("2021-09-11T11:47:37+02:00"), | 			CreatedAt: TimeMustParse("2021-09-11T11:47:37+02:00"), | ||||||
| 		}, | 		}, | ||||||
|  | 		"remote_account_1_status_4_poll_vote_local_account_1": { | ||||||
|  | 			ID:        "01JDQ0SX9QVVFHS7P8M1PA3SVG", | ||||||
|  | 			Choices:   []int{4}, | ||||||
|  | 			AccountID: "01F8MH1H7YV1Z7D2C8K2730QBF", | ||||||
|  | 			Account:   nil, | ||||||
|  | 			PollID:    "01JDQ0EZ5HM9T4WXRQ5WSVD40J", | ||||||
|  | 			Poll:      nil, | ||||||
|  | 			CreatedAt: TimeMustParse("2024-11-01T09:01:30+02:00"), | ||||||
|  | 		}, | ||||||
|  | 		"remote_account_1_status_4_poll_vote_local_account_2": { | ||||||
|  | 			ID:        "01JDQ0T3EEDN7SAVBQMQP4PR12", | ||||||
|  | 			Choices:   []int{4}, | ||||||
|  | 			AccountID: "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|  | 			Account:   nil, | ||||||
|  | 			PollID:    "01JDQ0EZ5HM9T4WXRQ5WSVD40J", | ||||||
|  | 			Poll:      nil, | ||||||
|  | 			CreatedAt: TimeMustParse("2024-11-01T09:02:30+02:00"), | ||||||
|  | 		}, | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -2341,7 +2457,6 @@ func NewTestMentions() map[string]*gtsmodel.Mention { | ||||||
| 			ID:               "01FCTA2Y6FGHXQA4ZE6N5NMNEX", | 			ID:               "01FCTA2Y6FGHXQA4ZE6N5NMNEX", | ||||||
| 			StatusID:         "01FCTA44PW9H1TB328S9AQXKDS", | 			StatusID:         "01FCTA44PW9H1TB328S9AQXKDS", | ||||||
| 			CreatedAt:        TimeMustParse("2022-05-14T13:21:09+02:00"), | 			CreatedAt:        TimeMustParse("2022-05-14T13:21:09+02:00"), | ||||||
| 			UpdatedAt:        TimeMustParse("2022-05-14T13:21:09+02:00"), |  | ||||||
| 			OriginAccountID:  "01F8MH1H7YV1Z7D2C8K2730QBF", | 			OriginAccountID:  "01F8MH1H7YV1Z7D2C8K2730QBF", | ||||||
| 			OriginAccountURI: "http://localhost:8080/users/the_mighty_zork", | 			OriginAccountURI: "http://localhost:8080/users/the_mighty_zork", | ||||||
| 			TargetAccountID:  "01F8MH5ZK5VRH73AKHQM6Y9VNX", | 			TargetAccountID:  "01F8MH5ZK5VRH73AKHQM6Y9VNX", | ||||||
|  | @ -2353,7 +2468,6 @@ func NewTestMentions() map[string]*gtsmodel.Mention { | ||||||
| 			ID:               "01FDF2HM2NF6FSRZCDEDV451CN", | 			ID:               "01FDF2HM2NF6FSRZCDEDV451CN", | ||||||
| 			StatusID:         "01FCQSQ667XHJ9AV9T27SJJSX5", | 			StatusID:         "01FCQSQ667XHJ9AV9T27SJJSX5", | ||||||
| 			CreatedAt:        TimeMustParse("2022-05-14T13:21:09+02:00"), | 			CreatedAt:        TimeMustParse("2022-05-14T13:21:09+02:00"), | ||||||
| 			UpdatedAt:        TimeMustParse("2022-05-14T13:21:09+02:00"), |  | ||||||
| 			OriginAccountID:  "01F8MH5NBDF2MV7CTC4Q5128HF", | 			OriginAccountID:  "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
| 			OriginAccountURI: "http://localhost:8080/users/1happyturtle", | 			OriginAccountURI: "http://localhost:8080/users/1happyturtle", | ||||||
| 			TargetAccountID:  "01F8MH1H7YV1Z7D2C8K2730QBF", | 			TargetAccountID:  "01F8MH1H7YV1Z7D2C8K2730QBF", | ||||||
|  | @ -2365,7 +2479,6 @@ func NewTestMentions() map[string]*gtsmodel.Mention { | ||||||
| 			ID:               "01FN3VKDEF4CN2W9TKX339BEHB", | 			ID:               "01FN3VKDEF4CN2W9TKX339BEHB", | ||||||
| 			StatusID:         "01FN3VJGFH10KR7S2PB0GFJZYG", | 			StatusID:         "01FN3VJGFH10KR7S2PB0GFJZYG", | ||||||
| 			CreatedAt:        TimeMustParse("2022-05-14T13:21:09+02:00"), | 			CreatedAt:        TimeMustParse("2022-05-14T13:21:09+02:00"), | ||||||
| 			UpdatedAt:        TimeMustParse("2022-05-14T13:21:09+02:00"), |  | ||||||
| 			OriginAccountID:  "01F8MH5NBDF2MV7CTC4Q5128HF", | 			OriginAccountID:  "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
| 			OriginAccountURI: "http://localhost:8080/users/1happyturtle", | 			OriginAccountURI: "http://localhost:8080/users/1happyturtle", | ||||||
| 			TargetAccountID:  "01F8MH1H7YV1Z7D2C8K2730QBF", | 			TargetAccountID:  "01F8MH1H7YV1Z7D2C8K2730QBF", | ||||||
|  | @ -2377,7 +2490,6 @@ func NewTestMentions() map[string]*gtsmodel.Mention { | ||||||
| 			ID:               "01FF26A6BGEKCZFWNEHXB2ZZ6M", | 			ID:               "01FF26A6BGEKCZFWNEHXB2ZZ6M", | ||||||
| 			StatusID:         "01FF25D5Q0DH7CHD57CTRS6WK0", | 			StatusID:         "01FF25D5Q0DH7CHD57CTRS6WK0", | ||||||
| 			CreatedAt:        TimeMustParse("2022-05-14T13:21:09+02:00"), | 			CreatedAt:        TimeMustParse("2022-05-14T13:21:09+02:00"), | ||||||
| 			UpdatedAt:        TimeMustParse("2022-05-14T13:21:09+02:00"), |  | ||||||
| 			OriginAccountID:  "01F8MH17FWEB39HZJ76B6VXSKF", | 			OriginAccountID:  "01F8MH17FWEB39HZJ76B6VXSKF", | ||||||
| 			OriginAccountURI: "http://localhost:8080/users/admin", | 			OriginAccountURI: "http://localhost:8080/users/admin", | ||||||
| 			TargetAccountID:  "01F8MH1H7YV1Z7D2C8K2730QBF", | 			TargetAccountID:  "01F8MH1H7YV1Z7D2C8K2730QBF", | ||||||
|  | @ -2389,7 +2501,6 @@ func NewTestMentions() map[string]*gtsmodel.Mention { | ||||||
| 			ID:               "01J5QVP69ANF1K4WHES6GA4WXP", | 			ID:               "01J5QVP69ANF1K4WHES6GA4WXP", | ||||||
| 			StatusID:         "01J5QVB9VC76NPPRQ207GG4DRZ", | 			StatusID:         "01J5QVB9VC76NPPRQ207GG4DRZ", | ||||||
| 			CreatedAt:        TimeMustParse("2024-02-20T12:41:37+02:00"), | 			CreatedAt:        TimeMustParse("2024-02-20T12:41:37+02:00"), | ||||||
| 			UpdatedAt:        TimeMustParse("2024-02-20T12:41:37+02:00"), |  | ||||||
| 			OriginAccountID:  "01F8MH17FWEB39HZJ76B6VXSKF", | 			OriginAccountID:  "01F8MH17FWEB39HZJ76B6VXSKF", | ||||||
| 			OriginAccountURI: "http://localhost:8080/users/admin", | 			OriginAccountURI: "http://localhost:8080/users/admin", | ||||||
| 			TargetAccountID:  "01F8MH5NBDF2MV7CTC4Q5128HF", | 			TargetAccountID:  "01F8MH5NBDF2MV7CTC4Q5128HF", | ||||||
|  | @ -2401,7 +2512,6 @@ func NewTestMentions() map[string]*gtsmodel.Mention { | ||||||
| 			ID:               "01HE7XQNMKTVC8MNPCE1JGK4J3", | 			ID:               "01HE7XQNMKTVC8MNPCE1JGK4J3", | ||||||
| 			StatusID:         "01HE7XJ1CG84TBKH5V9XKBVGF5", | 			StatusID:         "01HE7XJ1CG84TBKH5V9XKBVGF5", | ||||||
| 			CreatedAt:        TimeMustParse("2023-11-02T12:44:25+02:00"), | 			CreatedAt:        TimeMustParse("2023-11-02T12:44:25+02:00"), | ||||||
| 			UpdatedAt:        TimeMustParse("2023-11-02T12:44:25+02:00"), |  | ||||||
| 			OriginAccountID:  "01FHMQX3GAABWSM0S2VZEC2SWC", | 			OriginAccountID:  "01FHMQX3GAABWSM0S2VZEC2SWC", | ||||||
| 			OriginAccountURI: "http://example.org/users/Some_User", | 			OriginAccountURI: "http://example.org/users/Some_User", | ||||||
| 			TargetAccountID:  "01F8MH17FWEB39HZJ76B6VXSKF", | 			TargetAccountID:  "01F8MH17FWEB39HZJ76B6VXSKF", | ||||||
|  | @ -3490,6 +3600,102 @@ func NewTestInteractionRequests() map[string]*gtsmodel.InteractionRequest { | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | func NewTestStatusEdits() map[string]*gtsmodel.StatusEdit { | ||||||
|  | 	return map[string]*gtsmodel.StatusEdit{ | ||||||
|  | 		"local_account_1_status_9_edit_1": { | ||||||
|  | 			ID:             "01JDPZCZ2Y9KSGZW0R7ZG8T8Y2", | ||||||
|  | 			Content:        "<p>this is the original status</p>", | ||||||
|  | 			ContentWarning: "", | ||||||
|  | 			Text:           "this is the original status", | ||||||
|  | 			Language:       "en", | ||||||
|  | 			Sensitive:      util.Ptr(false), | ||||||
|  | 			AttachmentIDs:  nil, | ||||||
|  | 			PollOptions:    nil, | ||||||
|  | 			PollVotes:      nil, | ||||||
|  | 			StatusID:       "01JDPZC707CKDN8N4QVWM4Z1NR", | ||||||
|  | 			CreatedAt:      TimeMustParse("2024-11-01T11:00:00+02:00"), | ||||||
|  | 		}, | ||||||
|  | 		"local_account_1_status_9_edit_2": { | ||||||
|  | 			ID:             "01JDPZDADMD1T9HKF94RECF7PP", | ||||||
|  | 			Content:        "<p>this is the first status edit! now with content-warning</p>", | ||||||
|  | 			ContentWarning: "edited status", | ||||||
|  | 			Text:           "this is the first status edit! now with content-warning", | ||||||
|  | 			Language:       "en", | ||||||
|  | 			Sensitive:      util.Ptr(false), | ||||||
|  | 			AttachmentIDs:  nil, | ||||||
|  | 			PollOptions:    nil, | ||||||
|  | 			PollVotes:      nil, | ||||||
|  | 			StatusID:       "01JDPZC707CKDN8N4QVWM4Z1NR", | ||||||
|  | 			CreatedAt:      TimeMustParse("2024-11-01T11:01:00+02:00"), | ||||||
|  | 		}, | ||||||
|  | 		"local_account_2_status_9_edit_1": { | ||||||
|  | 			ID:             "01JDPZPBXAX0M02YSEPB21KX4R", | ||||||
|  | 			Content:        "<p>this is the original status</p>", | ||||||
|  | 			ContentWarning: "", | ||||||
|  | 			Text:           "this is the original status", | ||||||
|  | 			Language:       "en", | ||||||
|  | 			Sensitive:      util.Ptr(false), | ||||||
|  | 			AttachmentIDs:  nil, | ||||||
|  | 			PollOptions:    nil, | ||||||
|  | 			PollVotes:      nil, | ||||||
|  | 			StatusID:       "01JDPZEZ77X1NX0TY9M10BK1HM", | ||||||
|  | 			CreatedAt:      TimeMustParse("2024-11-01T10:00:00+02:00"), | ||||||
|  | 		}, | ||||||
|  | 		"local_account_2_status_9_edit_2": { | ||||||
|  | 			ID:             "01JDPZPJHKP7E3M0YQXEXPS1YT", | ||||||
|  | 			Content:        "<p>now edited to have some media!</p>", | ||||||
|  | 			ContentWarning: "edit with media attachments", | ||||||
|  | 			Text:           "now edited to have some media!", | ||||||
|  | 			Language:       "en", | ||||||
|  | 			Sensitive:      util.Ptr(true), | ||||||
|  | 			AttachmentIDs:  []string{"01JDQ164HM08SGJ7ZEK9003Z4B"}, | ||||||
|  | 			PollOptions:    nil, | ||||||
|  | 			PollVotes:      nil, | ||||||
|  | 			StatusID:       "01JDPZEZ77X1NX0TY9M10BK1HM", | ||||||
|  | 			CreatedAt:      TimeMustParse("2024-11-01T10:01:00+02:00"), | ||||||
|  | 		}, | ||||||
|  | 		"local_account_2_status_9_edit_3": { | ||||||
|  | 			ID:             "01JDPZPY3F85Y7B78ETRXEMWD9", | ||||||
|  | 			Content:        "<p>now edited to remove the media</p>", | ||||||
|  | 			ContentWarning: "edit missing previous media attachments", | ||||||
|  | 			Text:           "now edited to remove the media", | ||||||
|  | 			Language:       "en", | ||||||
|  | 			Sensitive:      util.Ptr(false), | ||||||
|  | 			AttachmentIDs:  nil, | ||||||
|  | 			PollOptions:    nil, | ||||||
|  | 			PollVotes:      nil, | ||||||
|  | 			StatusID:       "01JDPZEZ77X1NX0TY9M10BK1HM", | ||||||
|  | 			CreatedAt:      TimeMustParse("2024-11-01T10:02:00+02:00"), | ||||||
|  | 		}, | ||||||
|  | 		"remote_account_1_status_4_edit_1": { | ||||||
|  | 			ID:             "01JDQ07ZZ4FGP13YN8TF63P5A6", | ||||||
|  | 			Content:        "<p>this is the original status, with a poll!</p>", | ||||||
|  | 			ContentWarning: "", | ||||||
|  | 			Text:           "this is the original status, with a poll!", | ||||||
|  | 			Language:       "en", | ||||||
|  | 			Sensitive:      util.Ptr(false), | ||||||
|  | 			AttachmentIDs:  nil, | ||||||
|  | 			PollOptions:    []string{"yes", "no", "spiderman"}, | ||||||
|  | 			PollVotes:      []int{42, 42, 69}, | ||||||
|  | 			StatusID:       "01JDQ07JZTX9CMDJP67CNA71YD", | ||||||
|  | 			CreatedAt:      TimeMustParse("2024-11-01T09:00:00+02:00"), | ||||||
|  | 		}, | ||||||
|  | 		"remote_account_1_status_4_edit_2": { | ||||||
|  | 			ID:             "01JDQ08AYQC0G6413VAHA51CV9", | ||||||
|  | 			Content:        "<p>this is the first status edit! now with a different poll!</p>", | ||||||
|  | 			ContentWarning: "edited status", | ||||||
|  | 			Text:           "this is the first status edit! now with a different poll!", | ||||||
|  | 			Language:       "en", | ||||||
|  | 			Sensitive:      util.Ptr(false), | ||||||
|  | 			AttachmentIDs:  nil, | ||||||
|  | 			PollOptions:    []string{"yes", "no", "maybe", "i don't know", "can you repeat the question"}, | ||||||
|  | 			PollVotes:      []int{0, 0, 0, 0, 1}, | ||||||
|  | 			StatusID:       "01JDQ07JZTX9CMDJP67CNA71YD", | ||||||
|  | 			CreatedAt:      TimeMustParse("2024-11-01T09:01:00+02:00"), | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
| // GetSignatureForActivity prepares a mock HTTP request as if it were going to deliver activity to destination signed for privkey and pubKeyID, signs the request and returns the header values. | // GetSignatureForActivity prepares a mock HTTP request as if it were going to deliver activity to destination signed for privkey and pubKeyID, signs the request and returns the header values. | ||||||
| func GetSignatureForActivity(activity pub.Activity, pubKeyID string, privkey *rsa.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) { | func GetSignatureForActivity(activity pub.Activity, pubKeyID string, privkey *rsa.PrivateKey, destination *url.URL) (signatureHeader string, digestHeader string, dateHeader string) { | ||||||
| 	// convert the activity into json bytes | 	// convert the activity into json bytes | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue