mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-30 22:02:25 -05:00 
			
		
		
		
	
				commit
				
					
						995e61bb07
					
				
			
		
					 24 changed files with 1247 additions and 135 deletions
				
			
		
							
								
								
									
										3
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
										
									
									
										vendored
									
									
								
							|  | @ -3,3 +3,6 @@ | ||||||
| 
 | 
 | ||||||
| # exclude built documentation, since readthedocs will build it for us anyway | # exclude built documentation, since readthedocs will build it for us anyway | ||||||
| /docs/_build | /docs/_build | ||||||
|  | 
 | ||||||
|  | # exclude coverage report | ||||||
|  | cp.out | ||||||
|  |  | ||||||
							
								
								
									
										208
									
								
								PROGRESS.md
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										208
									
								
								PROGRESS.md
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,208 @@ | ||||||
|  | # Progress | ||||||
|  | 
 | ||||||
|  | * [ ] Client-To-Server (Client REST API) | ||||||
|  |   * [ ] Token and sign-in | ||||||
|  |     * [x] /api/v1/apps POST                                 (Create an application) | ||||||
|  |     * [ ] /api/v1/apps/verify_credentials GET               (Verify an application works) | ||||||
|  |     * [x] /oauth/authorize GET                              (Show authorize page to user) | ||||||
|  |     * [x] /oauth/authorize POST                             (Get an oauth access code for an app/user) | ||||||
|  |     * [ ] /oauth/token POST                                 (Obtain a user-level access token) | ||||||
|  |     * [ ] /oauth/revoke POST                                (Revoke a user-level access token) | ||||||
|  |     * [x] /auth/sign_in GET                                 (Show form for user signin) | ||||||
|  |     * [x] /auth/sign_in POST                                (Validate username and password and sign user in) | ||||||
|  |   * [ ] Accounts | ||||||
|  |     * [ ] /api/v1/accounts POST                             (Register a new account) | ||||||
|  |     * [ ] /api/v1/accounts/verify_credentials GET           (Verify account credentials with a user token) | ||||||
|  |     * [ ] /api/v1/accounts/update_credentials PATCH         (Update user's display name/preferences) | ||||||
|  |     * [ ] /api/v1/accounts/:id GET                          (Get account information) | ||||||
|  |     * [ ] /api/v1/accounts/:id/statuses GET                 (Get an account's statuses) | ||||||
|  |     * [ ] /api/v1/accounts/:id/followers GET                (Get an account's followers) | ||||||
|  |     * [ ] /api/v1/accounts/:id/following GET                (Get an account's following) | ||||||
|  |     * [ ] /api/v1/accounts/:id/featured_tags GET            (Get an account's featured tags) | ||||||
|  |     * [ ] /api/v1/accounts/:id/lists GET                    (Get lists containing this account) | ||||||
|  |     * [ ] /api/v1/accounts/:id/identity_proofs GET          (Get identity proofs for this account) | ||||||
|  |     * [ ] /api/v1/accounts/:id/follow POST                  (Follow this account) | ||||||
|  |     * [ ] /api/v1/accounts/:id/unfollow POST                (Unfollow this account) | ||||||
|  |     * [ ] /api/v1/accounts/:id/block POST                   (Block this account) | ||||||
|  |     * [ ] /api/v1/accounts/:id/unblock POST                 (Unblock this account) | ||||||
|  |     * [ ] /api/v1/accounts/:id/mute POST                    (Mute this account) | ||||||
|  |     * [ ] /api/v1/accounts/:id/unmute POST                  (Unmute this account) | ||||||
|  |     * [ ] /api/v1/accounts/:id/pin POST                     (Feature this account on profile) | ||||||
|  |     * [ ] /api/v1/accounts/:id/unpin POST                   (Remove this account from profile) | ||||||
|  |     * [ ] /api/v1/accounts/:id/note POST                    (Make a personal note about this account) | ||||||
|  |     * [ ] /api/v1/accounts/relationships GET                (Check relationships with accounts) | ||||||
|  |     * [ ] /api/v1/accounts/search GET                       (Search for an account) | ||||||
|  |   * [ ] Bookmarks | ||||||
|  |     * [ ] /api/v1/bookmarks GET                             (See bookmarked statuses) | ||||||
|  |   * [ ] Favourites | ||||||
|  |     * [ ] /api/v1/favourites GET                            (See faved statuses) | ||||||
|  |   * [ ] Mutes | ||||||
|  |     * [ ] /api/v1/mutes GET                                 (See list of muted accounts) | ||||||
|  |   * [ ] Blocks | ||||||
|  |     * [ ] /api/v1/blocks GET                                (See list of blocked accounts) | ||||||
|  |   * [ ] Domain Blocks | ||||||
|  |     * [ ] /api/v1/domain_blocks GET                         (See list of domain blocks) | ||||||
|  |     * [ ] /api/v1/domain_blocks POST                        (Create a domain block) | ||||||
|  |     * [ ] /api/v1/domain_blocks DELETE                      (Remove a domain block) | ||||||
|  |   * [ ] Filters | ||||||
|  |     * [ ] /api/v1/filters GET                               (Get list of filters) | ||||||
|  |     * [ ] /api/v1/filters/:id GET                           (View a filter) | ||||||
|  |     * [ ] /api/v1/filters POST                              (Create a filter) | ||||||
|  |     * [ ] /api/v1/filters/:id PUT                           (Update a filter) | ||||||
|  |     * [ ] /api/v1/filters/:id DELETE                        (Remove a filter) | ||||||
|  |   * [ ] Reports | ||||||
|  |     * [ ] /api/v1/reports POST                              (File a report) | ||||||
|  |   * [ ] Follow Requests | ||||||
|  |     * [ ] /api/v1/follow_requests GET                       (View pending follow requests) | ||||||
|  |     * [ ] /api/v1/follow_requests/:id/authorize POST        (Accept a follow request) | ||||||
|  |     * [ ] /api/v1/follow_requests/:id/reject POST           (Reject a follow request) | ||||||
|  |   * [ ] Endorsements | ||||||
|  |     * [ ] /api/v1/endorsements GET                          (View existing endorsements) | ||||||
|  |   * [ ] Featured Tags | ||||||
|  |     * [ ] /api/v1/featured_tags GET                         (View featured tags) | ||||||
|  |     * [ ] /api/v1/featured_tags POST                        (Feature a tag) | ||||||
|  |     * [ ] /api/v1/featured_tags/:id DELETE                  (Unfeature a tag) | ||||||
|  |     * [ ] /api/v1/featured_tags/suggestions GET             (See most used tags) | ||||||
|  |   * [ ] Preferences | ||||||
|  |     * [ ] /api/v1/preferences GET                           (Get user preferences) | ||||||
|  |   * [ ] Suggestions | ||||||
|  |     * [ ] /api/v1/suggestions GET                           (Get suggested accounts to follow) | ||||||
|  |     * [ ] /api/v1/suggestions/:account_id DELETE            (Delete a suggestion) | ||||||
|  |   * [ ] Statuses | ||||||
|  |     * [ ] /api/v1/statuses POST                             (Create a new status) | ||||||
|  |     * [ ] /api/v1/statuses/:id GET                          (View an existing status) | ||||||
|  |     * [ ] /api/v1/statuses/:id DELETE                       (Delete a status) | ||||||
|  |     * [ ] /api/v1/statuses/:id/context GET                  (View statuses above and below status ID) | ||||||
|  |     * [ ] /api/v1/statuses/:id/reblogged_by GET             (See who has reblogged a status) | ||||||
|  |     * [ ] /api/v1/statuses/:id/favourited_by GET            (See who has faved a status) | ||||||
|  |     * [ ] /api/v1/statuses/:id/favourite POST               (Fave a status) | ||||||
|  |     * [ ] /api/v1/statuses/:id/favourite POST               (Unfave a status) | ||||||
|  |     * [ ] /api/v1/statuses/:id/reblog POST                  (Reblog a status) | ||||||
|  |     * [ ] /api/v1/statuses/:id/unreblog POST                (Undo a reblog) | ||||||
|  |     * [ ] /api/v1/statuses/:id/bookmark POST                (Bookmark a status) | ||||||
|  |     * [ ] /api/v1/statuses/:id/unbookmark POST              (Undo a bookmark) | ||||||
|  |     * [ ] /api/v1/statuses/:id/mute POST                    (Mute notifications on a status) | ||||||
|  |     * [ ] /api/v1/statuses/:id/unmute POST                  (Unmute notifications on a status) | ||||||
|  |     * [ ] /api/v1/statuses/:id/pin POST                     (Pin a status to profile) | ||||||
|  |     * [ ] /api/v1/statuses/:id/unpin POST                   (Unpin a status from profile) | ||||||
|  |   * [ ] Media | ||||||
|  |     * [ ] /api/v1/media POST                                (Upload a media attachment) | ||||||
|  |     * [ ] /api/v1/media/:id GET                             (Get a media attachment) | ||||||
|  |     * [ ] /api/v1/media/:id PUT                             (Update an attachment) | ||||||
|  |   * [ ] Polls | ||||||
|  |     * [ ] /api/v1/polls/:id GET                             (Show a poll) | ||||||
|  |     * [ ] /api/v1/polls/:id/votes POST                      (Vote on a poll) | ||||||
|  |   * [ ] Scheduled Statuses | ||||||
|  |     * [ ] /api/v1/scheduled_statuses GET                    (View scheduled statuses) | ||||||
|  |     * [ ] /api/v1/scheduled_statuses/:id GET                (View a scheduled status) | ||||||
|  |     * [ ] /api/v1/scheduled_statuses/:id PUT                (Schedule a status) | ||||||
|  |     * [ ] /api/v1/scheduled_statuses/:id DELETE             (Cancel a scheduled status) | ||||||
|  |   * [ ] Timelines | ||||||
|  |     * [ ] /api/v1/timelines/public GET                      (See the public/federated timeline) | ||||||
|  |     * [ ] /api/v1/timelines/tag/:hashtag GET                (Get public statuses that use hashtag) | ||||||
|  |     * [ ] /api/v1/timelines/home GET                        (View statuses from followed users) | ||||||
|  |     * [ ] /api/v1/timelines/list/:list_id GET               (Get statuses in given list) | ||||||
|  |   * [ ] Conversations | ||||||
|  |     * [ ] /api/v1/conversations GET                         (Get a list of direct message convos) | ||||||
|  |     * [ ] /api/v1/conversations/:id DELETE                  (Delete a direct message convo) | ||||||
|  |     * [ ] /api/v1/conversations/:id POST                    (Mark a conversation as read) | ||||||
|  |   * [ ] Lists | ||||||
|  |     * [ ] /api/v1/lists GET                                 (Show a list of lists) | ||||||
|  |     * [ ] /api/v1/lists/:id GET                             (Show a single list) | ||||||
|  |     * [ ] /api/v1/lists POST                                (Create a new list) | ||||||
|  |     * [ ] /api/v1/lists/:id PUT                             (Update a list) | ||||||
|  |     * [ ] /api/v1/lists/:id DELETE                          (Delete a list) | ||||||
|  |     * [ ] /api/v1/lists/:id/accounts GET                    (View which accounts are in a list) | ||||||
|  |     * [ ] /api/v1/lists/:id/accounts POST                   (Add accounts to a list) | ||||||
|  |     * [ ] /api/v1/lists/:id/accounts DELETE                 (Remove accounts from a list) | ||||||
|  |   * [ ] Markers | ||||||
|  |     * [ ] /api/v1/markers GET                               (Get saved timeline position) | ||||||
|  |     * [ ] /api/v1/markers POST                              (Save timeline position) | ||||||
|  |   * [ ] Streaming | ||||||
|  |     * [ ] /api/v1/streaming WEBSOCKETS                      (Stream live events to user via websockets) | ||||||
|  |   * [ ] Notifications | ||||||
|  |     * [ ] /api/v1/notifications GET                         (Get list of notifications) | ||||||
|  |     * [ ] /api/v1/notifications/:id GET                     (Get a single notification) | ||||||
|  |     * [ ] /api/v1/notifications/clear POST                  (Clear all notifications) | ||||||
|  |     * [ ] /api/v1/notifications/:id POST                    (Clear a single notification) | ||||||
|  |   * [ ] Push | ||||||
|  |     * [ ] /api/v1/push/subscription POST                    (Subscribe to push notifications) | ||||||
|  |     * [ ] /api/v1/push/subscription GET                     (Get current subscription) | ||||||
|  |     * [ ] /api/v1/push/subscription PUT                     (Change notification types) | ||||||
|  |     * [ ] /api/v1/push/subscription DELETE                  (Delete current subscription) | ||||||
|  |   * [ ] Search | ||||||
|  |     * [ ] /api/v2/search GET                                (Get search query results) | ||||||
|  |   * [ ] Instance | ||||||
|  |     * [ ] /api/v1/instance GET                              (Get instance information) | ||||||
|  |     * [ ] /api/v1/instance PATCH                            (Update instance information) | ||||||
|  |     * [ ] /api/v1/instance/peers GET                        (Get list of federated servers) | ||||||
|  |     * [ ] /api/v1/instance/activity GET                     (Instance activity over the last 3 months, binned weekly.) | ||||||
|  |   * [ ] Trends | ||||||
|  |     * [ ] /api/v1/trends GET                                (Get a list of trending tags for the last week) | ||||||
|  |   * [ ] Directory | ||||||
|  |     * [ ] /api/v1/directory GET                             (Show profiles this server is aware of.) | ||||||
|  |   * [ ] Custom Emojis | ||||||
|  |     * [ ] /api/v1/custom_emojis GET                         (Show this server's custom emoji) | ||||||
|  |   * [ ] Admin | ||||||
|  |     * [ ] /api/v1/admin/accounts GET                        (View accounts filtered by criteria) | ||||||
|  |     * [ ] /api/v1/admin/accounts/:id GET                    (View admin level info about an account) | ||||||
|  |     * [ ] /api/v1/admin/accounts/:id/action POST            (Perform an admin action on account) | ||||||
|  |     * [ ] /api/v1/admin/accounts/:id/approve POST           (Approve pending account) | ||||||
|  |     * [ ] /api/v1/admin/accounts/:id/reject POST            (Deny pending account) | ||||||
|  |     * [ ] /api/v1/admin/accounts/:id/enable POST            (Reenable a disabled account) | ||||||
|  |     * [ ] /api/v1/admin/accounts/:id/unsilence POST         (Unsilence a silenced account) | ||||||
|  |     * [ ] /api/v1/admin/accounts/:id/unsuspend POST         (Unsuspend a suspended account) | ||||||
|  |     * [ ] /api/v1/admin/reports GET                         (View all reports) | ||||||
|  |     * [ ] /api/v1/admin/reports/:id GET                     (View a single report) | ||||||
|  |     * [ ] /api/v1/admin/reports/:id/assign_to_self POST     (Assign a report to the current admin account) | ||||||
|  |     * [ ] /api/v1/admin/reports/:id/unassign POST           (Unassign a report) | ||||||
|  |     * [ ] /api/v1/admin/reports/:id/resolve POST            (Mark a report as resolved) | ||||||
|  |     * [ ] /api/v1/admin/reports/:id/reopen POST             (Reopen a closed report) | ||||||
|  |   * [ ] Announcements | ||||||
|  |     * [ ] /api/v1/announcements GET                         (Show all current announcements) | ||||||
|  |     * [ ] /api/v1/announcements/:id/dismiss POST            (Mark an announcement as read) | ||||||
|  |     * [ ] /api/v1/announcements/:id/reactions/:name PUT     (Add a reaction to an announcement) | ||||||
|  |     * [ ] /api/v1/announcements/:id/reactions/:name DELETE  (Remove a reaction from an announcement) | ||||||
|  |   * [ ] Proofs | ||||||
|  |     * [ ] /api/proofs GET                                   (View identity proofs) | ||||||
|  |   * [ ] Oembed | ||||||
|  |     * [ ] /api/oembed GET                                   (Get oembed metadata for a status URL) | ||||||
|  | * [ ] Server-To-Server (Federation protocol) | ||||||
|  |   * [ ] Mechanism to trigger side effects from client AP | ||||||
|  |   * [ ] Federation modes | ||||||
|  |     * [ ] 'Slow' federation | ||||||
|  |       * [ ] Reputation scoring system for instances | ||||||
|  |     * [ ] 'Greedy' federation | ||||||
|  |     * [ ] No federation (insulate this instance from the Fediverse) | ||||||
|  |       * [ ] Allowlist | ||||||
|  | * [ ] Storage | ||||||
|  |   * [x] Internal/statuses/preferences etc | ||||||
|  |     * [x] Postgres interface | ||||||
|  |   * [ ] Media storage | ||||||
|  |     * [ ] Local storage interface | ||||||
|  |     * [ ] S3 storage interface | ||||||
|  | * [ ] Cache | ||||||
|  |   * [ ] In-memory cache | ||||||
|  | * [ ] Security features | ||||||
|  |   * [ ] Authorization middleware | ||||||
|  |   * [ ] Rate limiting middleware | ||||||
|  |   * [ ] Scope middleware | ||||||
|  |   * [ ] Permissions/acl middleware for admins+moderators | ||||||
|  | * [ ] Documentation | ||||||
|  |   * [ ] Swagger API documentation | ||||||
|  |   * [ ] ReadTheDocs.io documentation | ||||||
|  |   * [ ] Deployment documentation | ||||||
|  |   * [ ] App creation guide | ||||||
|  | * [ ] Tooling | ||||||
|  |   * [ ] Database migration tool | ||||||
|  |   * [ ] Admin CLI tool | ||||||
|  | * [ ] Build | ||||||
|  |   * [ ] Docker containerization | ||||||
|  |     * [ ] Dockerfile | ||||||
|  |     * [ ] docker-compose.yml | ||||||
|  | * [ ] Tests | ||||||
|  |   * [ ] Unit/integration | ||||||
|  |     * [ ] 25% coverage | ||||||
|  |     * [ ] 50% coverage | ||||||
|  |     * [ ] 90%+ coverage | ||||||
|  |   * [ ] Benchmarking | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| # GoToSocial | # GoToSocial | ||||||
| 
 | 
 | ||||||
| <img src="https://img.shields.io/liberapay/patrons/dumpsterqueer.svg?logo=liberapay"> <img src="https://img.shields.io/liberapay/receives/dumpsterqueer.svg?logo=liberapay"> |   | ||||||
| 
 | 
 | ||||||
| Federated social media software. | Federated social media software. | ||||||
| 
 | 
 | ||||||
|  | @ -30,9 +30,13 @@ Among other things: | ||||||
| * Easily-configurable character limit. | * Easily-configurable character limit. | ||||||
| * Groups and group posting. | * Groups and group posting. | ||||||
| 
 | 
 | ||||||
|  | ## Implementation Status | ||||||
|  | 
 | ||||||
|  | For an up-to-date view on progress made towards a v1.0.0 release, see [here](./PROGRESS.md). | ||||||
|  | 
 | ||||||
| ## Contact | ## Contact | ||||||
| 
 | 
 | ||||||
| For questions and comments, you can reach out to Tobi on the Fediverse <a rel="me" href="https://ondergrond.org/@dumpsterqueer">here</a> or mail admin@gotosocial.org. | For questions and comments, you can reach out to Tobi on the Fediverse [here](https://ondergrond.org/@dumpsterqueer) or mail admin@gotosocial.org. | ||||||
| 
 | 
 | ||||||
| ## Sponsorship | ## Sponsorship | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -95,6 +95,14 @@ func main() { | ||||||
| 				Value:   "postgres", | 				Value:   "postgres", | ||||||
| 				EnvVars: []string{envNames.DbDatabase}, | 				EnvVars: []string{envNames.DbDatabase}, | ||||||
| 			}, | 			}, | ||||||
|  | 
 | ||||||
|  | 			// TEMPLATE FLAGS | ||||||
|  | 			&cli.StringFlag{ | ||||||
|  | 				Name:    flagNames.TemplateBaseDir, | ||||||
|  | 				Usage:   "Basedir for html templating files for rendering pages and composing emails", | ||||||
|  | 				Value:   "./web/template/", | ||||||
|  | 				EnvVars: []string{envNames.TemplateBaseDir}, | ||||||
|  | 			}, | ||||||
| 		}, | 		}, | ||||||
| 		Commands: []*cli.Command{ | 		Commands: []*cli.Command{ | ||||||
| 			{ | 			{ | ||||||
|  | @ -137,12 +145,12 @@ func main() { | ||||||
| func runAction(c *cli.Context, a action.GTSAction) error { | func runAction(c *cli.Context, a action.GTSAction) error { | ||||||
| 
 | 
 | ||||||
| 	// create a new *config.Config based on the config path provided... | 	// create a new *config.Config based on the config path provided... | ||||||
| 	conf, err := config.New(c.String(config.GetFlagNames().ConfigPath)) | 	conf, err := config.FromFile(c.String(config.GetFlagNames().ConfigPath)) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fmt.Errorf("error creating config: %s", err) | 		return fmt.Errorf("error creating config: %s", err) | ||||||
| 	} | 	} | ||||||
| 	// ... and the flags set on the *cli.Context by urfave | 	// ... and the flags set on the *cli.Context by urfave | ||||||
| 	conf.ParseFlags(c) | 	conf.ParseCLIFlags(c) | ||||||
| 
 | 
 | ||||||
| 	// create a logger with the log level, formatting, and output splitter already set | 	// create a logger with the log level, formatting, and output splitter already set | ||||||
| 	log, err := log.New(conf.LogLevel) | 	log, err := log.New(conf.LogLevel) | ||||||
|  |  | ||||||
|  | @ -60,3 +60,10 @@ db: | ||||||
|   # Examples: ["mydb","postgres","gotosocial"] |   # Examples: ["mydb","postgres","gotosocial"] | ||||||
|   # Default: "postgres" |   # Default: "postgres" | ||||||
|   database: "postgres" |   database: "postgres" | ||||||
|  | 
 | ||||||
|  | # Config pertaining to templating of web pages/email notifications and the like | ||||||
|  | template: | ||||||
|  |   # String. Directory from which gotosocial will attempt to load html templates (.tmpl files). | ||||||
|  |   # Examples: ["/some/absolute/path/", "./relative/path/", "../../some/weird/path/"] | ||||||
|  |   # Default: "./web/template/" | ||||||
|  |   baseDir: "./web/template/" | ||||||
|  |  | ||||||
							
								
								
									
										5
									
								
								go.mod
									
										
									
									
									
								
							
							
						
						
									
										5
									
								
								go.mod
									
										
									
									
									
								
							|  | @ -3,13 +3,14 @@ module github.com/gotosocial/gotosocial | ||||||
| go 1.16 | go 1.16 | ||||||
| 
 | 
 | ||||||
| require ( | require ( | ||||||
|  | 	github.com/gin-contrib/sessions v0.0.3 | ||||||
| 	github.com/gin-gonic/gin v1.6.3 | 	github.com/gin-gonic/gin v1.6.3 | ||||||
| 	github.com/go-fed/activity v1.0.0 | 	github.com/go-fed/activity v1.0.0 | ||||||
| 	github.com/go-pg/pg/extra/pgdebug v0.2.0 | 	github.com/go-pg/pg/extra/pgdebug v0.2.0 | ||||||
| 	github.com/go-pg/pg/v10 v10.8.0 | 	github.com/go-pg/pg/v10 v10.8.0 | ||||||
| 	github.com/golang/mock v1.4.4 // indirect | 	github.com/golang/mock v1.4.4 // indirect | ||||||
| 	github.com/google/uuid v1.2.0 // indirect | 	github.com/google/uuid v1.2.0 | ||||||
| 	github.com/gotosocial/oauth2/v4 v4.2.1-0.20210315164102-1f7842217e57 | 	github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318133800-45d321d259b3 | ||||||
| 	github.com/onsi/ginkgo v1.15.0 // indirect | 	github.com/onsi/ginkgo v1.15.0 // indirect | ||||||
| 	github.com/onsi/gomega v1.10.5 // indirect | 	github.com/onsi/gomega v1.10.5 // indirect | ||||||
| 	github.com/sirupsen/logrus v1.8.0 | 	github.com/sirupsen/logrus v1.8.0 | ||||||
|  |  | ||||||
							
								
								
									
										31
									
								
								go.sum
									
										
									
									
									
								
							
							
						
						
									
										31
									
								
								go.sum
									
										
									
									
									
								
							|  | @ -4,6 +4,9 @@ github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU= | ||||||
| github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= | github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY= | ||||||
| github.com/andybalholm/brotli v1.0.0 h1:7UCwP93aiSfvWpapti8g88vVVGp2qqtGyePsSuDafo4= | github.com/andybalholm/brotli v1.0.0 h1:7UCwP93aiSfvWpapti8g88vVVGp2qqtGyePsSuDafo4= | ||||||
| github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= | github.com/andybalholm/brotli v1.0.0/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= | ||||||
|  | github.com/boj/redistore v0.0.0-20180917114910-cd5dcc76aeff/go.mod h1:+RTT1BOk5P97fT2CiHkbFQwkK3mjsFAP6zCYV2aXtjw= | ||||||
|  | github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA= | ||||||
|  | github.com/bradleypeabody/gorilla-sessions-memcache v0.0.0-20181103040241-659414f458e1/go.mod h1:dkChI7Tbtx7H1Tj7TqGSZMOeGpMP5gLHtjroHd4agiI= | ||||||
| github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= | ||||||
| github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= | ||||||
| github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= | ||||||
|  | @ -24,10 +27,14 @@ github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWo | ||||||
| github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= | github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= | ||||||
| github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8= | github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8= | ||||||
| github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= | github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc= | ||||||
|  | github.com/gin-contrib/sessions v0.0.3 h1:PoBXki+44XdJdlgDqDrY5nDVe3Wk7wDV/UCOuLP6fBI= | ||||||
|  | github.com/gin-contrib/sessions v0.0.3/go.mod h1:8C/J6cad3Il1mWYYgtw0w+hqasmpvy25mPkXdOgeB9I= | ||||||
| github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= | ||||||
| github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= | ||||||
|  | github.com/gin-gonic/gin v1.5.0/go.mod h1:Nd6IXA8m5kNZdNEHMBd93KT+mdY3+bewLgRvmCsR2Do= | ||||||
| github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= | github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14= | ||||||
| github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= | github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M= | ||||||
|  | github.com/globalsign/mgo v0.0.0-20181015135952-eeefdecb41b8/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= | ||||||
| github.com/go-fed/activity v1.0.0 h1:j7w3auHZnVCjUcgA1mE+UqSOjFBhvW2Z2res3vNol+o= | github.com/go-fed/activity v1.0.0 h1:j7w3auHZnVCjUcgA1mE+UqSOjFBhvW2Z2res3vNol+o= | ||||||
| github.com/go-fed/activity v1.0.0/go.mod h1:v4QoPaAzjWZ8zN2VFVGL5ep9C02mst0hQYHUpQwso4Q= | github.com/go-fed/activity v1.0.0/go.mod h1:v4QoPaAzjWZ8zN2VFVGL5ep9C02mst0hQYHUpQwso4Q= | ||||||
| github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5 h1:WLvFZqoXnuVTBKA6U/1FnEHNQ0Rq0QM0rGhY8Tx6R1g= | github.com/go-fed/httpsig v0.1.1-0.20190914113940-c2de3672e5b5 h1:WLvFZqoXnuVTBKA6U/1FnEHNQ0Rq0QM0rGhY8Tx6R1g= | ||||||
|  | @ -41,8 +48,10 @@ github.com/go-pg/zerochecker v0.2.0 h1:pp7f72c3DobMWOb2ErtZsnrPaSvHd2W4o9//8HtF4 | ||||||
| github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= | github.com/go-pg/zerochecker v0.2.0/go.mod h1:NJZ4wKL0NmTtz0GKCoJ8kym6Xn/EQzXRl2OnAe7MmDo= | ||||||
| github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= | github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= | ||||||
| github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= | github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= | ||||||
|  | github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= | ||||||
| github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= | github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= | ||||||
| github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= | github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= | ||||||
|  | github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= | ||||||
| github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= | github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no= | ||||||
| github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= | github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA= | ||||||
| github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= | github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY= | ||||||
|  | @ -68,6 +77,7 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD | ||||||
| github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= | ||||||
| github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= | github.com/golang/protobuf v1.4.3 h1:JjCZWpVbqXDqFVmTfYWEVTMIYrL/NPdPSCHPJ0T/raM= | ||||||
| github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= | ||||||
|  | github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= | ||||||
| github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= | ||||||
| github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= | ||||||
| github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= | ||||||
|  | @ -84,20 +94,29 @@ github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= | ||||||
| github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | ||||||
| github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= | ||||||
| github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= | github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= | ||||||
|  | github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= | ||||||
|  | github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= | ||||||
|  | github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= | ||||||
|  | github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= | ||||||
|  | github.com/gorilla/sessions v1.1.1/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= | ||||||
|  | github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU= | ||||||
|  | github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= | ||||||
| github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= | github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= | ||||||
| github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= | ||||||
| github.com/gotosocial/oauth2/v4 v4.2.1-0.20210315164102-1f7842217e57 h1:+zKsBEkg1cbz7zJDms1KMU9vJBeBAlElS1SbK/x0Rvc= | github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318133800-45d321d259b3 h1:CKRz5d7mRum+UMR88Ue33tCYcej14WjUsB59C02DDqY= | ||||||
| github.com/gotosocial/oauth2/v4 v4.2.1-0.20210315164102-1f7842217e57/go.mod h1:zl5kwHf/atRUrY5yOyDnk49Us1Ygs0BzdW4jKAgoiP8= | github.com/gotosocial/oauth2/v4 v4.2.1-0.20210318133800-45d321d259b3/go.mod h1:zl5kwHf/atRUrY5yOyDnk49Us1Ygs0BzdW4jKAgoiP8= | ||||||
| github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= | ||||||
| github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= | github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk= | ||||||
| github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= | github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= | ||||||
| github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= | ||||||
| github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= | ||||||
|  | github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= | ||||||
| github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= | github.com/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns= | ||||||
| github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= | github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= | ||||||
| github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= | ||||||
| github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= | ||||||
| github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= | github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k= | ||||||
|  | github.com/kidstuff/mongostore v0.0.0-20181113001930-e650cd85ee4b/go.mod h1:g2nVr8KZVXJSS97Jo8pJ0jgq29P6H7dG0oplUA86MQw= | ||||||
| github.com/klauspost/compress v1.10.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= | github.com/klauspost/compress v1.10.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= | ||||||
| github.com/klauspost/compress v1.10.10 h1:a/y8CglcM7gLGYmlbP/stPE5sR3hbhFRUjCBfd/0B3I= | github.com/klauspost/compress v1.10.10 h1:a/y8CglcM7gLGYmlbP/stPE5sR3hbhFRUjCBfd/0B3I= | ||||||
| github.com/klauspost/compress v1.10.10/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= | github.com/klauspost/compress v1.10.10/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= | ||||||
|  | @ -107,13 +126,16 @@ github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfn | ||||||
| github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= | ||||||
| github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= | ||||||
| github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= | ||||||
|  | github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= | ||||||
| github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= | github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y= | ||||||
| github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= | github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII= | ||||||
| github.com/magefile/mage v1.10.0 h1:3HiXzCUY12kh9bIuyXShaVe529fJfyqoVM42o/uom2g= | github.com/magefile/mage v1.10.0 h1:3HiXzCUY12kh9bIuyXShaVe529fJfyqoVM42o/uom2g= | ||||||
| github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= | github.com/magefile/mage v1.10.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= | ||||||
| github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= | github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= | ||||||
|  | github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ= | ||||||
| github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= | github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= | ||||||
| github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= | github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= | ||||||
|  | github.com/memcachier/mc v2.0.1+incompatible/go.mod h1:7bkvFE61leUBvXz+yxsOnGBQSZpBSPIMUQSmmSHvuXc= | ||||||
| github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= | ||||||
| github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= | ||||||
| github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg= | ||||||
|  | @ -137,6 +159,8 @@ github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7 | ||||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||||
| github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= | ||||||
|  | github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438 h1:jnz/4VenymvySjE+Ez511s0pqVzkUOmr1fwCVytNNWk= | ||||||
|  | github.com/quasoft/memstore v0.0.0-20180925164028-84a050167438/go.mod h1:wTPjTepVu7uJBYgZ0SdWHQlIas582j6cn2jgk4DDdlg= | ||||||
| github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= | ||||||
| github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= | ||||||
| github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= | github.com/sclevine/agouti v3.0.0+incompatible/go.mod h1:b4WX9W9L1sfQKXeJf1mUTLZKJ48R1S7H23Ji7oFO5Bw= | ||||||
|  | @ -277,6 +301,7 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h | ||||||
| golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||||
| golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= | ||||||
| golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||||
|  | golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||||
| golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||||
| golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||||
| golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= | ||||||
|  | @ -339,6 +364,8 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8 | ||||||
| gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= | ||||||
| gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | ||||||
| gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= | ||||||
|  | gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= | ||||||
|  | gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= | ||||||
| gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= | ||||||
| gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= | ||||||
| gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= | ||||||
|  |  | ||||||
|  | @ -19,16 +19,19 @@ | ||||||
| package api | package api | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"net/http" | 	"fmt" | ||||||
|  | 	"os" | ||||||
|  | 	"path/filepath" | ||||||
| 
 | 
 | ||||||
|  | 	"github.com/gin-contrib/sessions" | ||||||
|  | 	"github.com/gin-contrib/sessions/memstore" | ||||||
| 	"github.com/gin-gonic/gin" | 	"github.com/gin-gonic/gin" | ||||||
| 	"github.com/gotosocial/gotosocial/internal/config" | 	"github.com/gotosocial/gotosocial/internal/config" | ||||||
| 	"github.com/sirupsen/logrus" | 	"github.com/sirupsen/logrus" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type Server interface { | type Server interface { | ||||||
| 	AttachHTTPHandler(method string, path string, handler http.HandlerFunc) | 	AttachHandler(method string, path string, handler gin.HandlerFunc) | ||||||
| 	AttachGinHandler(method string, path string, handler gin.HandlerFunc) |  | ||||||
| 	// AttachMiddleware(handler gin.HandlerFunc) | 	// AttachMiddleware(handler gin.HandlerFunc) | ||||||
| 	GetAPIGroup() *gin.RouterGroup | 	GetAPIGroup() *gin.RouterGroup | ||||||
| 	Start() | 	Start() | ||||||
|  | @ -60,16 +63,22 @@ func (s *server) Stop() { | ||||||
| 	// todo: shut down gracefully | 	// todo: shut down gracefully | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *server) AttachHTTPHandler(method string, path string, handler http.HandlerFunc) { | func (s *server) AttachHandler(method string, path string, handler gin.HandlerFunc) { | ||||||
| 	s.engine.Handle(method, path, gin.WrapH(handler)) | 	if method == "ANY" { | ||||||
| } | 		s.engine.Any(path, handler) | ||||||
| 
 | 	} else { | ||||||
| func (s *server) AttachGinHandler(method string, path string, handler gin.HandlerFunc) { |  | ||||||
| 		s.engine.Handle(method, path, handler) | 		s.engine.Handle(method, path, handler) | ||||||
|  | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func New(config *config.Config, logger *logrus.Logger) Server { | func New(config *config.Config, logger *logrus.Logger) Server { | ||||||
| 	engine := gin.New() | 	engine := gin.New() | ||||||
|  | 	store := memstore.NewStore([]byte("authentication-key"), []byte("encryption-keyencryption-key----")) | ||||||
|  | 	engine.Use(sessions.Sessions("gotosocial-session", store)) | ||||||
|  | 	cwd, _ := os.Getwd() | ||||||
|  | 	tmPath := filepath.Join(cwd, fmt.Sprintf("%s*", config.TemplateConfig.BaseDir)) | ||||||
|  | 	logger.Debugf("loading templates from %s", tmPath) | ||||||
|  | 	engine.LoadHTMLGlob(tmPath) | ||||||
| 	return &server{ | 	return &server{ | ||||||
| 		APIGroup: engine.Group("/api").Group("/v1"), | 		APIGroup: engine.Group("/api").Group("/v1"), | ||||||
| 		logger:   logger, | 		logger:   logger, | ||||||
|  |  | ||||||
|  | @ -30,22 +30,35 @@ type Config struct { | ||||||
| 	LogLevel        string          `yaml:"logLevel"` | 	LogLevel        string          `yaml:"logLevel"` | ||||||
| 	ApplicationName string          `yaml:"applicationName"` | 	ApplicationName string          `yaml:"applicationName"` | ||||||
| 	DBConfig        *DBConfig       `yaml:"db"` | 	DBConfig        *DBConfig       `yaml:"db"` | ||||||
|  | 	TemplateConfig  *TemplateConfig `yaml:"template"` | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // New returns a new config, or an error if something goes amiss. | // FromFile returns a new config from a file, or an error if something goes amiss. | ||||||
| // The path parameter is optional, for loading a configuration json from the given path. | func FromFile(path string) (*Config, error) { | ||||||
| func New(path string) (*Config, error) { | 	c, err := loadFromFile(path) | ||||||
| 	config := &Config{ | 	if err != nil { | ||||||
| 		DBConfig: &DBConfig{}, |  | ||||||
| 	} |  | ||||||
| 	if path != "" { |  | ||||||
| 		var err error |  | ||||||
| 		if config, err = loadFromFile(path); err != nil { |  | ||||||
| 		return nil, fmt.Errorf("error creating config: %s", err) | 		return nil, fmt.Errorf("error creating config: %s", err) | ||||||
| 	} | 	} | ||||||
| 	} | 	return c, nil | ||||||
|  | } | ||||||
| 
 | 
 | ||||||
| 	return config, nil | // Default returns a new config with default values. | ||||||
|  | // Not yet implemented. | ||||||
|  | func Default() *Config { | ||||||
|  | 	// TODO: find a way of doing this without code repetition, because having to | ||||||
|  | 	// repeat all values here and elsewhere is annoying and gonna be prone to mistakes. | ||||||
|  | 	return &Config{ | ||||||
|  | 		DBConfig:       &DBConfig{}, | ||||||
|  | 		TemplateConfig: &TemplateConfig{}, | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // Empty just returns an empty config | ||||||
|  | func Empty() *Config { | ||||||
|  | 	return &Config{ | ||||||
|  | 		DBConfig:       &DBConfig{}, | ||||||
|  | 		TemplateConfig: &TemplateConfig{}, | ||||||
|  | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // loadFromFile takes a path to a yaml file and attempts to load a Config object from it | // loadFromFile takes a path to a yaml file and attempts to load a Config object from it | ||||||
|  | @ -63,8 +76,8 @@ func loadFromFile(path string) (*Config, error) { | ||||||
| 	return config, nil | 	return config, nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // ParseFlags sets flags on the config using the provided Flags object | // ParseCLIFlags sets flags on the config using the provided Flags object | ||||||
| func (c *Config) ParseFlags(f KeyedFlags) { | func (c *Config) ParseCLIFlags(f KeyedFlags) { | ||||||
| 	fn := GetFlagNames() | 	fn := GetFlagNames() | ||||||
| 
 | 
 | ||||||
| 	// For all of these flags, we only want to set them on the config if: | 	// For all of these flags, we only want to set them on the config if: | ||||||
|  | @ -108,6 +121,11 @@ func (c *Config) ParseFlags(f KeyedFlags) { | ||||||
| 	if c.DBConfig.Database == "" || f.IsSet(fn.DbDatabase) { | 	if c.DBConfig.Database == "" || f.IsSet(fn.DbDatabase) { | ||||||
| 		c.DBConfig.Database = f.String(fn.DbDatabase) | 		c.DBConfig.Database = f.String(fn.DbDatabase) | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	// template flags | ||||||
|  | 	if c.TemplateConfig.BaseDir == "" || f.IsSet(fn.TemplateBaseDir) { | ||||||
|  | 		c.TemplateConfig.BaseDir = f.String(fn.TemplateBaseDir) | ||||||
|  | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // KeyedFlags is a wrapper for any type that can store keyed flags and give them back. | // KeyedFlags is a wrapper for any type that can store keyed flags and give them back. | ||||||
|  | @ -130,6 +148,7 @@ type Flags struct { | ||||||
| 	DbUser          string | 	DbUser          string | ||||||
| 	DbPassword      string | 	DbPassword      string | ||||||
| 	DbDatabase      string | 	DbDatabase      string | ||||||
|  | 	TemplateBaseDir string | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // GetFlagNames returns a struct containing the names of the various flags used for | // GetFlagNames returns a struct containing the names of the various flags used for | ||||||
|  | @ -145,6 +164,7 @@ func GetFlagNames() Flags { | ||||||
| 		DbUser:          "db-user", | 		DbUser:          "db-user", | ||||||
| 		DbPassword:      "db-password", | 		DbPassword:      "db-password", | ||||||
| 		DbDatabase:      "db-database", | 		DbDatabase:      "db-database", | ||||||
|  | 		TemplateBaseDir: "template-basedir", | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -161,5 +181,6 @@ func GetEnvNames() Flags { | ||||||
| 		DbUser:          "GTS_DB_USER", | 		DbUser:          "GTS_DB_USER", | ||||||
| 		DbPassword:      "GTS_DB_PASSWORD", | 		DbPassword:      "GTS_DB_PASSWORD", | ||||||
| 		DbDatabase:      "GTS_DB_DATABASE", | 		DbDatabase:      "GTS_DB_DATABASE", | ||||||
|  | 		TemplateBaseDir: "GTS_TEMPLATE_BASEDIR", | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										25
									
								
								internal/config/template.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								internal/config/template.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | ||||||
|  | /* | ||||||
|  |    GoToSocial | ||||||
|  |    Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org | ||||||
|  | 
 | ||||||
|  |    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 config | ||||||
|  | 
 | ||||||
|  | // TemplateConfig pertains to templating of web pages/email notifications and the like | ||||||
|  | type TemplateConfig struct { | ||||||
|  | 	// Directory from which gotosocial will attempt to load html templates (.tmpl files). | ||||||
|  | 	BaseDir string `yaml:"baseDir"` | ||||||
|  | } | ||||||
|  | @ -26,60 +26,130 @@ import ( | ||||||
| 	"time" | 	"time" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| // Account represents a GoToSocial user account | // Account represents either a local or a remote fediverse account, gotosocial or otherwise (mastodon, pleroma, etc) | ||||||
| type Account struct { | type Account struct { | ||||||
|  | 	/* | ||||||
|  | 		BASIC INFO | ||||||
|  | 	*/ | ||||||
|  | 
 | ||||||
|  | 	// id of this account in the local database; the end-user will never need to know this, it's strictly internal | ||||||
|  | 	ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` | ||||||
|  | 	// Username of the account, should just be a string of [a-z0-9_]. Can be added to domain to create the full username in the form ``[username]@[domain]`` eg., ``user_96@example.org`` | ||||||
|  | 	Username string `pg:",notnull,unique:userdomain"` // username and domain should be unique *with* each other | ||||||
|  | 	// Domain of the account, will be empty if this is a local account, otherwise something like ``example.org`` or ``mastodon.social``. Should be unique with username. | ||||||
|  | 	Domain string `pg:",unique:userdomain"` // username and domain | ||||||
|  | 
 | ||||||
|  | 	/* | ||||||
|  | 		ACCOUNT METADATA | ||||||
|  | 	*/ | ||||||
|  | 
 | ||||||
|  | 	// Avatar image for this account | ||||||
| 	Avatar | 	Avatar | ||||||
|  | 	// Header image for this account | ||||||
| 	Header | 	Header | ||||||
| 	URI                   string | 	// DisplayName for this account. Can be empty, then just the Username will be used for display purposes. | ||||||
| 	URL                   string |  | ||||||
| 	ID                    string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` |  | ||||||
| 	Username              string |  | ||||||
| 	Domain                string |  | ||||||
| 	Secret                string |  | ||||||
| 	PrivateKey            string |  | ||||||
| 	PublicKey             string |  | ||||||
| 	RemoteURL             string |  | ||||||
| 	CreatedAt             time.Time `pg:"type:timestamp,notnull"` |  | ||||||
| 	UpdatedAt             time.Time `pg:"type:timestamp,notnull"` |  | ||||||
| 	Note                  string |  | ||||||
| 	DisplayName string | 	DisplayName string | ||||||
| 	SubscriptionExpiresAt time.Time `pg:"type:timestamp"` | 	// a key/value map of fields that this account has added to their profile | ||||||
| 	Locked                bool |  | ||||||
| 	LastWebfingeredAt     time.Time `pg:"type:timestamp"` |  | ||||||
| 	InboxURL              string |  | ||||||
| 	OutboxURL             string |  | ||||||
| 	SharedInboxURL        string |  | ||||||
| 	FollowersURL          string |  | ||||||
| 	Protocol              int |  | ||||||
| 	Memorial              bool |  | ||||||
| 	MovedToAccountID      int |  | ||||||
| 	FeaturedCollectionURL string |  | ||||||
| 	Fields map[string]string | 	Fields map[string]string | ||||||
| 	ActorType             string | 	// A note that this account has on their profile (ie., the account's bio/description of themselves) | ||||||
|  | 	Note string | ||||||
|  | 	// Is this a memorial account, ie., has the user passed away? | ||||||
|  | 	Memorial bool | ||||||
|  | 	// This account has moved this account id in the database | ||||||
|  | 	MovedToAccountID int | ||||||
|  | 	// When was this account created? | ||||||
|  | 	CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` | ||||||
|  | 	// When was this account last updated? | ||||||
|  | 	UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` | ||||||
|  | 	// When should this account function until | ||||||
|  | 	SubscriptionExpiresAt time.Time `pg:"type:timestamp"` | ||||||
|  | 
 | ||||||
|  | 	/* | ||||||
|  | 		PRIVACY SETTINGS | ||||||
|  | 	*/ | ||||||
|  | 
 | ||||||
|  | 	// Does this account need an approval for new followers? | ||||||
|  | 	Locked bool | ||||||
|  | 	// Should this account be shown in the instance's profile directory? | ||||||
| 	Discoverable bool | 	Discoverable bool | ||||||
|  | 
 | ||||||
|  | 	/* | ||||||
|  | 		ACTIVITYPUB THINGS | ||||||
|  | 	*/ | ||||||
|  | 
 | ||||||
|  | 	// What is the activitypub URI for this account discovered by webfinger? | ||||||
|  | 	URI string `pg:",unique"` | ||||||
|  | 	// At which URL can we see the user account in a web browser? | ||||||
|  | 	URL string `pg:",unique"` | ||||||
|  | 	// RemoteURL where this account is located. Will be empty if this is a local account. | ||||||
|  | 	RemoteURL string `pg:",unique"` | ||||||
|  | 	// Last time this account was located using the webfinger API. | ||||||
|  | 	LastWebfingeredAt time.Time `pg:"type:timestamp"` | ||||||
|  | 	// Address of this account's activitypub inbox, for sending activity to | ||||||
|  | 	InboxURL string `pg:",unique"` | ||||||
|  | 	// Address of this account's activitypub outbox | ||||||
|  | 	OutboxURL string `pg:",unique"` | ||||||
|  | 	// Don't support shared inbox right now so this is just a stub for a future implementation | ||||||
|  | 	SharedInboxURL string `pg:",unique"` | ||||||
|  | 	// URL for getting the followers list of this account | ||||||
|  | 	FollowersURL string `pg:",unique"` | ||||||
|  | 	// URL for getting the featured collection list of this account | ||||||
|  | 	FeaturedCollectionURL string `pg:",unique"` | ||||||
|  | 	// What type of activitypub actor is this account? | ||||||
|  | 	ActorType string | ||||||
|  | 	// This account is associated with x account id | ||||||
| 	AlsoKnownAs string | 	AlsoKnownAs string | ||||||
| 	SilencedAt            time.Time `pg:"type:timestamp"` | 
 | ||||||
| 	SuspendedAt           time.Time `pg:"type:timestamp"` | 	/* | ||||||
| 	TrustLevel            int | 		CRYPTO FIELDS | ||||||
| 	HideCollections       bool | 	*/ | ||||||
|  | 
 | ||||||
|  | 	Secret string | ||||||
|  | 	// Privatekey for validating activitypub requests, will obviously only be defined for local accounts | ||||||
|  | 	PrivateKey string | ||||||
|  | 	// Publickey for encoding activitypub requests, will be defined for both local and remote accounts | ||||||
|  | 	PublicKey string | ||||||
|  | 
 | ||||||
|  | 	/* | ||||||
|  | 		ADMIN FIELDS | ||||||
|  | 	*/ | ||||||
|  | 
 | ||||||
|  | 	// When was this account set to have all its media shown as sensitive? | ||||||
| 	SensitizedAt time.Time `pg:"type:timestamp"` | 	SensitizedAt time.Time `pg:"type:timestamp"` | ||||||
|  | 	// When was this account silenced (eg., statuses only visible to followers, not public)? | ||||||
|  | 	SilencedAt time.Time `pg:"type:timestamp"` | ||||||
|  | 	// When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account) | ||||||
|  | 	SuspendedAt time.Time `pg:"type:timestamp"` | ||||||
|  | 	// How much do we trust this account 🤔 | ||||||
|  | 	TrustLevel int | ||||||
|  | 	// Should we hide this account's collections? | ||||||
|  | 	HideCollections bool | ||||||
|  | 	// id of the user that suspended this account through an admin action | ||||||
| 	SuspensionOrigin int | 	SuspensionOrigin int | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // Avatar represents the avatar for the account for display purposes | ||||||
| type Avatar struct { | type Avatar struct { | ||||||
|  | 	// File name of the avatar on local storage | ||||||
| 	AvatarFileName string | 	AvatarFileName string | ||||||
|  | 	// Gif? png? jpeg? | ||||||
| 	AvatarContentType string | 	AvatarContentType string | ||||||
| 	AvatarFileSize    int | 	AvatarFileSize    int | ||||||
| 	AvatarUpdatedAt   *time.Time `pg:"type:timestamp"` | 	AvatarUpdatedAt   *time.Time `pg:"type:timestamp"` | ||||||
|  | 	// Where can we retrieve the avatar? | ||||||
| 	AvatarRemoteURL            *url.URL `pg:"type:text"` | 	AvatarRemoteURL            *url.URL `pg:"type:text"` | ||||||
| 	AvatarStorageSchemaVersion int | 	AvatarStorageSchemaVersion int | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | // Header represents the header of the account for display purposes | ||||||
| type Header struct { | type Header struct { | ||||||
|  | 	// File name of the header on local storage | ||||||
| 	HeaderFileName string | 	HeaderFileName string | ||||||
|  | 	// Gif? png? jpeg? | ||||||
| 	HeaderContentType string | 	HeaderContentType string | ||||||
| 	HeaderFileSize    int | 	HeaderFileSize    int | ||||||
| 	HeaderUpdatedAt   *time.Time `pg:"type:timestamp"` | 	HeaderUpdatedAt   *time.Time `pg:"type:timestamp"` | ||||||
|  | 	// Where can we retrieve the header? | ||||||
| 	HeaderRemoteURL            *url.URL `pg:"type:text"` | 	HeaderRemoteURL            *url.URL `pg:"type:text"` | ||||||
| 	HeaderStorageSchemaVersion int | 	HeaderStorageSchemaVersion int | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										30
									
								
								internal/gtsmodel/application.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								internal/gtsmodel/application.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,30 @@ | ||||||
|  | /* | ||||||
|  |    GoToSocial | ||||||
|  |    Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org | ||||||
|  | 
 | ||||||
|  |    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 | ||||||
|  | 
 | ||||||
|  | type Application struct { | ||||||
|  | 	ID           string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` | ||||||
|  | 	Name         string | ||||||
|  | 	Website      string | ||||||
|  | 	RedirectURI  string `json:"redirect_uri"` | ||||||
|  | 	ClientID     string `json:"client_id"` | ||||||
|  | 	ClientSecret string `json:"client_secret"` | ||||||
|  | 	Scopes       string `json:"scopes"` | ||||||
|  | 	VapidKey     string `json:"vapid_key"` | ||||||
|  | } | ||||||
|  | @ -22,11 +22,11 @@ import "time" | ||||||
| 
 | 
 | ||||||
| type Status struct { | type Status struct { | ||||||
| 	ID             string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` | 	ID             string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` | ||||||
| 	URI            string | 	URI            string `pg:",unique"` | ||||||
| 	URL            string | 	URL            string `pg:",unique"` | ||||||
| 	Content        string | 	Content        string | ||||||
| 	CreatedAt      time.Time `pg:"type:timestamp,notnull"` | 	CreatedAt      time.Time `pg:"type:timestamp,notnull,default:now()"` | ||||||
| 	UpdatedAt      time.Time `pg:"type:timestamp,notnull"` | 	UpdatedAt      time.Time `pg:"type:timestamp,notnull,default:now()"` | ||||||
| 	Local          bool | 	Local          bool | ||||||
| 	AccountID      string | 	AccountID      string | ||||||
| 	InReplyToID    string | 	InReplyToID    string | ||||||
|  |  | ||||||
|  | @ -23,43 +23,98 @@ import ( | ||||||
| 	"time" | 	"time" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
|  | // User represents an actual human user of gotosocial. Note, this is a LOCAL gotosocial user, not a remote account. | ||||||
|  | // To cross reference this local user with their account (which can be local or remote), use the AccountID field. | ||||||
| type User struct { | type User struct { | ||||||
| 	ID                     string    `pg:"type:uuid,default:gen_random_uuid(),pk,notnull"` | 	/* | ||||||
| 	Email                  string    `pg:",notnull"` | 		BASIC INFO | ||||||
| 	CreatedAt              time.Time `pg:"type:timestamp,notnull"` | 	*/ | ||||||
| 	UpdatedAt              time.Time `pg:"type:timestamp,notnull"` | 
 | ||||||
|  | 	// id of this user in the local database; the end-user will never need to know this, it's strictly internal | ||||||
|  | 	ID string `pg:"type:uuid,default:gen_random_uuid(),pk,notnull,unique"` | ||||||
|  | 	// confirmed email address for this user, this should be unique -- only one email address registered per instance, multiple users per email are not supported | ||||||
|  | 	Email string `pg:",notnull,unique"` | ||||||
|  | 	// The id of the local gtsmodel.Account entry for this user, if it exists (unconfirmed users don't have an account yet) | ||||||
|  | 	AccountID string `pg:"default:'',notnull,unique"` | ||||||
|  | 	// The encrypted password of this user, generated using https://pkg.go.dev/golang.org/x/crypto/bcrypt#GenerateFromPassword. A salt is included so we're safe against 🌈 tables | ||||||
| 	EncryptedPassword string `pg:",notnull"` | 	EncryptedPassword string `pg:",notnull"` | ||||||
| 	ResetPasswordToken     string | 
 | ||||||
| 	ResetPasswordSentAt    time.Time `pg:"type:timestamp"` | 	/* | ||||||
| 	SignInCount            int | 		USER METADATA | ||||||
|  | 	*/ | ||||||
|  | 
 | ||||||
|  | 	// When was this user created? | ||||||
|  | 	CreatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` | ||||||
|  | 	// From what IP was this user created? | ||||||
|  | 	SignUpIP net.IP | ||||||
|  | 	// When was this user updated (eg., password changed, email address changed)? | ||||||
|  | 	UpdatedAt time.Time `pg:"type:timestamp,notnull,default:now()"` | ||||||
|  | 	// When did this user sign in for their current session? | ||||||
| 	CurrentSignInAt time.Time `pg:"type:timestamp"` | 	CurrentSignInAt time.Time `pg:"type:timestamp"` | ||||||
| 	LastSignInAt           time.Time `pg:"type:timestamp"` | 	// What's the most recent IP of this user | ||||||
| 	CurrentSignInIP net.IP | 	CurrentSignInIP net.IP | ||||||
|  | 	// When did this user last sign in? | ||||||
|  | 	LastSignInAt time.Time `pg:"type:timestamp"` | ||||||
|  | 	// What's the previous IP of this user? | ||||||
| 	LastSignInIP net.IP | 	LastSignInIP net.IP | ||||||
| 	Admin                  bool | 	// How many times has this user signed in? | ||||||
| 	ConfirmationToken      string | 	SignInCount int | ||||||
| 	ConfirmedAt            time.Time `pg:"type:timestamp"` | 	// id of the user who invited this user (who let this guy in?) | ||||||
| 	ConfirmationSentAt     time.Time `pg:"type:timestamp"` | 	InviteID string | ||||||
| 	UnconfirmedEmail       string | 	// What languages does this user want to see? | ||||||
|  | 	ChosenLanguages []string | ||||||
|  | 	// What languages does this user not want to see? | ||||||
|  | 	FilteredLanguages []string | ||||||
|  | 	// In what timezone/locale is this user located? | ||||||
| 	Locale string | 	Locale string | ||||||
|  | 	// Which application id created this user? See gtsmodel.Application | ||||||
|  | 	CreatedByApplicationID string | ||||||
|  | 	// When did we last contact this user | ||||||
|  | 	LastEmailedAt time.Time `pg:"type:timestamp"` | ||||||
|  | 
 | ||||||
|  | 	/* | ||||||
|  | 		USER CONFIRMATION | ||||||
|  | 	*/ | ||||||
|  | 
 | ||||||
|  | 	// What confirmation token did we send this user/what are we expecting back? | ||||||
|  | 	ConfirmationToken string | ||||||
|  | 	// When did the user confirm their email address | ||||||
|  | 	ConfirmedAt time.Time `pg:"type:timestamp"` | ||||||
|  | 	// When did we send email confirmation to this user? | ||||||
|  | 	ConfirmationSentAt time.Time `pg:"type:timestamp"` | ||||||
|  | 	// Email address that hasn't yet been confirmed | ||||||
|  | 	UnconfirmedEmail string | ||||||
|  | 
 | ||||||
|  | 	/* | ||||||
|  | 		ACL FLAGS | ||||||
|  | 	*/ | ||||||
|  | 
 | ||||||
|  | 	// Is this user a moderator? | ||||||
|  | 	Moderator bool | ||||||
|  | 	// Is this user an admin? | ||||||
|  | 	Admin bool | ||||||
|  | 	// Is this user disabled from posting? | ||||||
|  | 	Disabled bool | ||||||
|  | 	// Has this user been approved by a moderator? | ||||||
|  | 	Approved bool | ||||||
|  | 
 | ||||||
|  | 	/* | ||||||
|  | 		USER SECURITY | ||||||
|  | 	*/ | ||||||
|  | 
 | ||||||
|  | 	// The generated token that the user can use to reset their password | ||||||
|  | 	ResetPasswordToken string | ||||||
|  | 	// When did we email the user their reset-password email? | ||||||
|  | 	ResetPasswordSentAt time.Time `pg:"type:timestamp"` | ||||||
|  | 
 | ||||||
| 	EncryptedOTPSecret     string | 	EncryptedOTPSecret     string | ||||||
| 	EncryptedOTPSecretIv   string | 	EncryptedOTPSecretIv   string | ||||||
| 	EncryptedOTPSecretSalt string | 	EncryptedOTPSecretSalt string | ||||||
| 	ConsumedTimestamp      int |  | ||||||
| 	OTPRequiredForLogin    bool | 	OTPRequiredForLogin    bool | ||||||
| 	LastEmailedAt          time.Time `pg:"type:timestamp"` |  | ||||||
| 	OTPBackupCodes         []string | 	OTPBackupCodes         []string | ||||||
| 	FilteredLanguages      []string | 	ConsumedTimestamp      int | ||||||
| 	AccountID              string `pg:",notnull"` |  | ||||||
| 	Disabled               bool |  | ||||||
| 	Moderator              bool |  | ||||||
| 	InviteID               string |  | ||||||
| 	RememberToken          string | 	RememberToken          string | ||||||
| 	ChosenLanguages        []string |  | ||||||
| 	CreatedByApplicationID string |  | ||||||
| 	Approved               bool |  | ||||||
| 	SignInToken            string | 	SignInToken            string | ||||||
| 	SignInTokenSentAt      time.Time `pg:"type:timestamp"` | 	SignInTokenSentAt      time.Time `pg:"type:timestamp"` | ||||||
| 	WebauthnID             string | 	WebauthnID             string | ||||||
| 	SignUpIP               net.IP |  | ||||||
| } | } | ||||||
|  |  | ||||||
							
								
								
									
										9
									
								
								internal/oauth/html.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								internal/oauth/html.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,9 @@ | ||||||
|  | package oauth | ||||||
|  | 
 | ||||||
|  | const ( | ||||||
|  | 	signInHTML = ` | ||||||
|  | ` | ||||||
|  | 
 | ||||||
|  | 	authorizeHTML = ` | ||||||
|  | ` | ||||||
|  | ) | ||||||
|  | @ -19,9 +19,17 @@ | ||||||
| package oauth | package oauth | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
|  | 	"fmt" | ||||||
|  | 	"net/http" | ||||||
|  | 	"net/url" | ||||||
|  | 
 | ||||||
|  | 	"github.com/gin-contrib/sessions" | ||||||
|  | 	"github.com/gin-gonic/gin" | ||||||
| 	"github.com/go-pg/pg/v10" | 	"github.com/go-pg/pg/v10" | ||||||
|  | 	"github.com/google/uuid" | ||||||
| 	"github.com/gotosocial/gotosocial/internal/api" | 	"github.com/gotosocial/gotosocial/internal/api" | ||||||
| 	"github.com/gotosocial/gotosocial/internal/gtsmodel" | 	"github.com/gotosocial/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/gotosocial/gotosocial/pkg/mastotypes" | ||||||
| 	"github.com/gotosocial/oauth2/v4" | 	"github.com/gotosocial/oauth2/v4" | ||||||
| 	"github.com/gotosocial/oauth2/v4/errors" | 	"github.com/gotosocial/oauth2/v4/errors" | ||||||
| 	"github.com/gotosocial/oauth2/v4/manage" | 	"github.com/gotosocial/oauth2/v4/manage" | ||||||
|  | @ -37,12 +45,43 @@ type API struct { | ||||||
| 	log     *logrus.Logger | 	log     *logrus.Logger | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | type login struct { | ||||||
|  | 	Email    string `form:"username"` | ||||||
|  | 	Password string `form:"password"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | type code struct { | ||||||
|  | 	Code string `form:"code"` | ||||||
|  | } | ||||||
|  | 
 | ||||||
| func New(ts oauth2.TokenStore, cs oauth2.ClientStore, conn *pg.DB, log *logrus.Logger) *API { | func New(ts oauth2.TokenStore, cs oauth2.ClientStore, conn *pg.DB, log *logrus.Logger) *API { | ||||||
| 	manager := manage.NewDefaultManager() | 	manager := manage.NewDefaultManager() | ||||||
| 	manager.MapTokenStorage(ts) | 	manager.MapTokenStorage(ts) | ||||||
| 	manager.MapClientStorage(cs) | 	manager.MapClientStorage(cs) | ||||||
|  | 	manager.SetAuthorizeCodeTokenCfg(manage.DefaultAuthorizeCodeTokenCfg) | ||||||
|  | 	sc := &server.Config{ | ||||||
|  | 		TokenType: "Bearer", | ||||||
|  | 		// Must follow the spec. | ||||||
|  | 		AllowGetAccessRequest: false, | ||||||
|  | 		// Support only the non-implicit flow. | ||||||
|  | 		AllowedResponseTypes: []oauth2.ResponseType{oauth2.Code}, | ||||||
|  | 		// Allow: | ||||||
|  | 		// - Authorization Code (for first & third parties) | ||||||
|  | 		// - Refreshing Tokens | ||||||
|  | 		// | ||||||
|  | 		// Deny: | ||||||
|  | 		// - Resource owner secrets (password grant) | ||||||
|  | 		// - Client secrets | ||||||
|  | 		AllowedGrantTypes: []oauth2.GrantType{ | ||||||
|  | 			oauth2.AuthorizationCode, | ||||||
|  | 			oauth2.Refreshing, | ||||||
|  | 		}, | ||||||
|  | 		AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{ | ||||||
|  | 			oauth2.CodeChallengePlain, | ||||||
|  | 		}, | ||||||
|  | 	} | ||||||
| 
 | 
 | ||||||
| 	srv := server.NewDefaultServer(manager) | 	srv := server.NewServer(sc, manager) | ||||||
| 	srv.SetInternalErrorHandler(func(err error) *errors.Response { | 	srv.SetInternalErrorHandler(func(err error) *errors.Response { | ||||||
| 		log.Errorf("internal oauth error: %s", err) | 		log.Errorf("internal oauth error: %s", err) | ||||||
| 		return nil | 		return nil | ||||||
|  | @ -52,15 +91,29 @@ func New(ts oauth2.TokenStore, cs oauth2.ClientStore, conn *pg.DB, log *logrus.L | ||||||
| 		log.Errorf("internal response error: %s", re.Error) | 		log.Errorf("internal response error: %s", re.Error) | ||||||
| 	}) | 	}) | ||||||
| 
 | 
 | ||||||
| 	return &API{ | 	api := &API{ | ||||||
| 		manager: manager, | 		manager: manager, | ||||||
| 		server:  srv, | 		server:  srv, | ||||||
| 		conn:    conn, | 		conn:    conn, | ||||||
| 		log:     log, | 		log:     log, | ||||||
| 	} | 	} | ||||||
|  | 
 | ||||||
|  | 	api.server.SetUserAuthorizationHandler(api.UserAuthorizationHandler) | ||||||
|  | 	api.server.SetClientInfoHandler(server.ClientFormHandler) | ||||||
|  | 	return api | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (a *API) AddRoutes(s api.Server) error { | func (a *API) AddRoutes(s api.Server) error { | ||||||
|  | 	s.AttachHandler(http.MethodPost, "/api/v1/apps", a.AppsPOSTHandler) | ||||||
|  | 
 | ||||||
|  | 	s.AttachHandler(http.MethodGet, "/auth/sign_in", a.SignInGETHandler) | ||||||
|  | 	s.AttachHandler(http.MethodPost, "/auth/sign_in", a.SignInPOSTHandler) | ||||||
|  | 
 | ||||||
|  | 	s.AttachHandler(http.MethodPost, "/oauth/token", a.TokenPOSTHandler) | ||||||
|  | 
 | ||||||
|  | 	s.AttachHandler(http.MethodGet, "/oauth/authorize", a.AuthorizeGETHandler) | ||||||
|  | 	s.AttachHandler(http.MethodPost, "/oauth/authorize", a.AuthorizePOSTHandler) | ||||||
|  | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -68,28 +121,326 @@ func incorrectPassword() (string, error) { | ||||||
| 	return "", errors.New("password/email combination was incorrect") | 	return "", errors.New("password/email combination was incorrect") | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (a *API) PasswordAuthorizationHandler(email string, password string) (userid string, err error) { | /* | ||||||
|  | 	MAIN HANDLERS -- serve these through a server/router | ||||||
|  | */ | ||||||
|  | 
 | ||||||
|  | // AppsPOSTHandler should be served at https://example.org/api/v1/apps | ||||||
|  | // It is equivalent to: https://docs.joinmastodon.org/methods/apps/ | ||||||
|  | func (a *API) AppsPOSTHandler(c *gin.Context) { | ||||||
|  | 	l := a.log.WithField("func", "AppsPOSTHandler") | ||||||
|  | 	l.Trace("entering AppsPOSTHandler") | ||||||
|  | 
 | ||||||
|  | 	form := &mastotypes.ApplicationPOSTRequest{} | ||||||
|  | 	if err := c.ShouldBind(form); err != nil { | ||||||
|  | 		c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// permitted length for most fields | ||||||
|  | 	permittedLength := 64 | ||||||
|  | 	// redirect can be a bit bigger because we probably need to encode data in the redirect uri | ||||||
|  | 	permittedRedirect := 256 | ||||||
|  | 
 | ||||||
|  | 	// check lengths of fields before proceeding so the user can't spam huge entries into the database | ||||||
|  | 	if len(form.ClientName) > permittedLength { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("client_name must be less than %d bytes", permittedLength)}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if len(form.Website) > permittedLength { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("website must be less than %d bytes", permittedLength)}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if len(form.RedirectURIs) > permittedRedirect { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("redirect_uris must be less than %d bytes", permittedRedirect)}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	if len(form.Scopes) > permittedLength { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("scopes must be less than %d bytes", permittedLength)}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// set default 'read' for scopes if it's not set | ||||||
|  | 	var scopes string | ||||||
|  | 	if form.Scopes == "" { | ||||||
|  | 		scopes = "read" | ||||||
|  | 	} else { | ||||||
|  | 		scopes = form.Scopes | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// generate new IDs for this application and its associated client | ||||||
|  | 	clientID := uuid.NewString() | ||||||
|  | 	clientSecret := uuid.NewString() | ||||||
|  | 	vapidKey := uuid.NewString() | ||||||
|  | 
 | ||||||
|  | 	// generate the application to put in the database | ||||||
|  | 	app := >smodel.Application{ | ||||||
|  | 		Name:         form.ClientName, | ||||||
|  | 		Website:      form.Website, | ||||||
|  | 		RedirectURI:  form.RedirectURIs, | ||||||
|  | 		ClientID:     clientID, | ||||||
|  | 		ClientSecret: clientSecret, | ||||||
|  | 		Scopes:       scopes, | ||||||
|  | 		VapidKey:     vapidKey, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// chuck it in the db | ||||||
|  | 	if _, err := a.conn.Model(app).Insert(); err != nil { | ||||||
|  | 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// now we need to model an oauth client from the application that the oauth library can use | ||||||
|  | 	oc := &oauthClient{ | ||||||
|  | 		ID:     clientID, | ||||||
|  | 		Secret: clientSecret, | ||||||
|  | 		Domain: form.RedirectURIs, | ||||||
|  | 		UserID: "", // This client isn't yet associated with a specific user,  it's just an app client right now | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// chuck it in the db | ||||||
|  | 	if _, err := a.conn.Model(oc).Insert(); err != nil { | ||||||
|  | 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// done, return the new app information per the spec here: https://docs.joinmastodon.org/methods/apps/ | ||||||
|  | 	c.JSON(http.StatusOK, app) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SignInGETHandler should be served at https://example.org/auth/sign_in. | ||||||
|  | // The idea is to present a sign in page to the user, where they can enter their username and password. | ||||||
|  | // The form will then POST to the sign in page, which will be handled by SignInPOSTHandler | ||||||
|  | func (a *API) SignInGETHandler(c *gin.Context) { | ||||||
|  | 	a.log.WithField("func", "SignInGETHandler").Trace("serving sign in html") | ||||||
|  | 	c.HTML(http.StatusOK, "sign-in.tmpl", gin.H{}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SignInPOSTHandler should be served at https://example.org/auth/sign_in. | ||||||
|  | // The idea is to present a sign in page to the user, where they can enter their username and password. | ||||||
|  | // The handler will then redirect to the auth handler served at /auth | ||||||
|  | func (a *API) SignInPOSTHandler(c *gin.Context) { | ||||||
|  | 	l := a.log.WithField("func", "SignInPOSTHandler") | ||||||
|  | 	s := sessions.Default(c) | ||||||
|  | 	form := &login{} | ||||||
|  | 	if err := c.ShouldBind(form); err != nil { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 	l.Tracef("parsed form: %+v", form) | ||||||
|  | 
 | ||||||
|  | 	userid, err := a.ValidatePassword(form.Email, form.Password) | ||||||
|  | 	if err != nil { | ||||||
|  | 		c.String(http.StatusForbidden, err.Error()) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	s.Set("username", userid) | ||||||
|  | 	if err := s.Save(); err != nil { | ||||||
|  | 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	l.Trace("redirecting to auth page") | ||||||
|  | 	c.Redirect(http.StatusFound, "/oauth/authorize") | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // TokenPOSTHandler should be served as a POST at https://example.org/oauth/token | ||||||
|  | // The idea here is to serve an oauth access token to a user, which can be used for authorizing against non-public APIs. | ||||||
|  | // See https://docs.joinmastodon.org/methods/apps/oauth/#obtain-a-token | ||||||
|  | func (a *API) TokenPOSTHandler(c *gin.Context) { | ||||||
|  | 	l := a.log.WithField("func", "TokenHandler") | ||||||
|  | 	l.Trace("entered token handler, will now go to server.HandleTokenRequest") | ||||||
|  | 	if err := a.server.HandleTokenRequest(c.Writer, c.Request); err != nil { | ||||||
|  | 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // AuthorizeGETHandler should be served as GET at https://example.org/oauth/authorize | ||||||
|  | // The idea here is to present an oauth authorize page to the user, with a button | ||||||
|  | // that they have to click to accept. See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user | ||||||
|  | func (a *API) AuthorizeGETHandler(c *gin.Context) { | ||||||
|  | 	l := a.log.WithField("func", "AuthorizeGETHandler") | ||||||
|  | 	s := sessions.Default(c) | ||||||
|  | 
 | ||||||
|  | 	// Username will be set in the session by AuthorizePOSTHandler if the caller has already gone through the authentication flow | ||||||
|  | 	// If it's not set, then we don't know yet who the user is, so we need to redirect them to the sign in page. | ||||||
|  | 	v := s.Get("username") | ||||||
|  | 	if username, ok := v.(string); !ok || username == "" { | ||||||
|  | 		l.Trace("username was empty, parsing form then redirecting to sign in page") | ||||||
|  | 
 | ||||||
|  | 		// first make sure they've filled out the authorize form with the required values | ||||||
|  | 		form := &mastotypes.OAuthAuthorize{} | ||||||
|  | 		if err := c.ShouldBind(form); err != nil { | ||||||
|  | 			c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 		l.Tracef("parsed form: %+v", form) | ||||||
|  | 
 | ||||||
|  | 		// these fields are *required* so check 'em | ||||||
|  | 		if form.ResponseType == "" || form.ClientID == "" || form.RedirectURI == "" { | ||||||
|  | 			c.JSON(http.StatusBadRequest, gin.H{"error": "missing one of: response_type, client_id or redirect_uri"}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// save these values from the form so we can use them elsewhere in the session | ||||||
|  | 		s.Set("force_login", form.ForceLogin) | ||||||
|  | 		s.Set("response_type", form.ResponseType) | ||||||
|  | 		s.Set("client_id", form.ClientID) | ||||||
|  | 		s.Set("redirect_uri", form.RedirectURI) | ||||||
|  | 		s.Set("scope", form.Scope) | ||||||
|  | 		if err := s.Save(); err != nil { | ||||||
|  | 			c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) | ||||||
|  | 			return | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// send them to the sign in page so we can tell who they are | ||||||
|  | 		c.Redirect(http.StatusFound, "/auth/sign_in") | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Check if we have a code already. If we do, it means the user used urn:ietf:wg:oauth:2.0:oob as their redirect URI | ||||||
|  | 	// and were sent here, which means they just want the code displayed so they can use it out of band. | ||||||
|  | 	code := &code{} | ||||||
|  | 	if err := c.Bind(code); err != nil { | ||||||
|  | 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// the authorize template will either: | ||||||
|  | 	// 1. Display the code to the user if they're already authorized and were redirected here because they selected urn:ietf:wg:oauth:2.0:oob. | ||||||
|  | 	// 2. Display a form where they can get some information about the app that's trying to authorize, and approve it, which will then go to AuthorizePOSTHandler | ||||||
|  | 	l.Trace("serving authorize html") | ||||||
|  | 	c.HTML(http.StatusOK, "authorize.tmpl", gin.H{ | ||||||
|  | 		"code": code.Code, | ||||||
|  | 	}) | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // AuthorizePOSTHandler should be served as POST at https://example.org/oauth/authorize | ||||||
|  | // The idea here is to present an oauth authorize page to the user, with a button | ||||||
|  | // that they have to click to accept. See here: https://docs.joinmastodon.org/methods/apps/oauth/#authorize-a-user | ||||||
|  | func (a *API) AuthorizePOSTHandler(c *gin.Context) { | ||||||
|  | 	l := a.log.WithField("func", "AuthorizePOSTHandler") | ||||||
|  | 	s := sessions.Default(c) | ||||||
|  | 
 | ||||||
|  | 	v := s.Get("username") | ||||||
|  | 	if username, ok := v.(string); !ok || username == "" { | ||||||
|  | 		c.JSON(http.StatusUnauthorized, gin.H{"error": "you are not signed in"}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	values := url.Values{} | ||||||
|  | 
 | ||||||
|  | 	if v, ok := s.Get("force_login").(string); !ok { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": "session missing force_login"}) | ||||||
|  | 		return | ||||||
|  | 	} else { | ||||||
|  | 		values.Add("force_login", v) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if v, ok := s.Get("response_type").(string); !ok { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": "session missing response_type"}) | ||||||
|  | 		return | ||||||
|  | 	} else { | ||||||
|  | 		values.Add("response_type", v) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if v, ok := s.Get("client_id").(string); !ok { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": "session missing client_id"}) | ||||||
|  | 		return | ||||||
|  | 	} else { | ||||||
|  | 		values.Add("client_id", v) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if v, ok := s.Get("redirect_uri").(string); !ok { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": "session missing redirect_uri"}) | ||||||
|  | 		return | ||||||
|  | 	} else { | ||||||
|  | 		// todo: explain this little hack | ||||||
|  | 		if v == "urn:ietf:wg:oauth:2.0:oob" { | ||||||
|  | 			v = "http://localhost:8080/oauth/authorize" | ||||||
|  | 		} | ||||||
|  | 		values.Add("redirect_uri", v) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if v, ok := s.Get("scope").(string); !ok { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": "session missing scope"}) | ||||||
|  | 		return | ||||||
|  | 	} else { | ||||||
|  | 		values.Add("scope", v) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if v, ok := s.Get("username").(string); !ok { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": "session missing username"}) | ||||||
|  | 		return | ||||||
|  | 	} else { | ||||||
|  | 		values.Add("username", v) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	c.Request.Form = values | ||||||
|  | 	l.Tracef("values on request set to %+v", c.Request.Form) | ||||||
|  | 
 | ||||||
|  | 	if err := s.Save(); err != nil { | ||||||
|  | 		c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := a.server.HandleAuthorizeRequest(c.Writer, c.Request); err != nil { | ||||||
|  | 		c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | /* | ||||||
|  | 	SUB-HANDLERS -- don't serve these directly, they should be attached to the oauth2 server | ||||||
|  | */ | ||||||
|  | 
 | ||||||
|  | // PasswordAuthorizationHandler takes a username (in this case, we use an email address) | ||||||
|  | // and a password. The goal is to authenticate the password against the one for that email | ||||||
|  | // address stored in the database. If OK, we return the userid (a uuid) for that user, | ||||||
|  | // so that it can be used in further Oauth flows to generate a token/retreieve an oauth client from the db. | ||||||
|  | func (a *API) ValidatePassword(email string, password string) (userid string, err error) { | ||||||
|  | 	l := a.log.WithField("func", "PasswordAuthorizationHandler") | ||||||
|  | 
 | ||||||
|  | 	// make sure an email/password was provided and bail if not | ||||||
|  | 	if email == "" || password == "" { | ||||||
|  | 		l.Debug("email or password was not provided") | ||||||
|  | 		return incorrectPassword() | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	// first we select the user from the database based on email address, bail if no user found for that email | 	// first we select the user from the database based on email address, bail if no user found for that email | ||||||
| 	gtsUser := >smodel.User{} | 	gtsUser := >smodel.User{} | ||||||
| 	if err := a.conn.Model(gtsUser).Where("email = ?", email).Select(); err != nil { | 	if err := a.conn.Model(gtsUser).Where("email = ?", email).Select(); err != nil { | ||||||
| 		a.log.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) | 		l.Debugf("user %s was not retrievable from db during oauth authorization attempt: %s", email, err) | ||||||
| 		return incorrectPassword() | 		return incorrectPassword() | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// make sure a password is actually set and bail if not | 	// make sure a password is actually set and bail if not | ||||||
| 	if gtsUser.EncryptedPassword == "" { | 	if gtsUser.EncryptedPassword == "" { | ||||||
| 		a.log.Warnf("encrypted password for user %s was empty for some reason", gtsUser.Email) | 		l.Warnf("encrypted password for user %s was empty for some reason", gtsUser.Email) | ||||||
| 		return incorrectPassword() | 		return incorrectPassword() | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// compare the provided password with the encrypted one from the db, bail if they don't match | 	// compare the provided password with the encrypted one from the db, bail if they don't match | ||||||
| 	if err := bcrypt.CompareHashAndPassword([]byte(gtsUser.EncryptedPassword), []byte(password)); err != nil { | 	if err := bcrypt.CompareHashAndPassword([]byte(gtsUser.EncryptedPassword), []byte(password)); err != nil { | ||||||
| 		a.log.Debugf("password hash didn't match for user %s during login attempt: %s", gtsUser.Email, err) | 		l.Debugf("password hash didn't match for user %s during login attempt: %s", gtsUser.Email, err) | ||||||
| 		return incorrectPassword() | 		return incorrectPassword() | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	// If we've made it this far the email/password is correct so we need the oauth client-id of the user | 	// If we've made it this far the email/password is correct, so we can just return the id of the user. | ||||||
| 	// This is, conveniently, the same as the user ID, so we can just return it. |  | ||||||
| 	userid = gtsUser.ID | 	userid = gtsUser.ID | ||||||
|  | 	l.Tracef("returning (%s, %s)", userid, err) | ||||||
| 	return | 	return | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // UserAuthorizationHandler gets the user's ID from the 'username' field of the request form, | ||||||
|  | // or redirects to the /auth/sign_in page, if this key is not present. | ||||||
|  | func (a *API) UserAuthorizationHandler(w http.ResponseWriter, r *http.Request) (userID string, err error) { | ||||||
|  | 	l := a.log.WithField("func", "UserAuthorizationHandler") | ||||||
|  | 	userID = r.FormValue("username") | ||||||
|  | 	if userID == "" { | ||||||
|  | 		l.Trace("username was empty, redirecting to sign in page") | ||||||
|  | 		http.Redirect(w, r, "/auth/sign_in", http.StatusFound) | ||||||
|  | 		return "", nil | ||||||
|  | 	} | ||||||
|  | 	l.Tracef("returning (%s, %s)", userID, err) | ||||||
|  | 	return userID, err | ||||||
|  | } | ||||||
|  |  | ||||||
							
								
								
									
										130
									
								
								internal/oauth/oauth_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								internal/oauth/oauth_test.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,130 @@ | ||||||
|  | package oauth | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"context" | ||||||
|  | 	"testing" | ||||||
|  | 	"time" | ||||||
|  | 
 | ||||||
|  | 	"github.com/go-pg/pg/v10" | ||||||
|  | 	"github.com/go-pg/pg/v10/orm" | ||||||
|  | 	"github.com/gotosocial/gotosocial/internal/api" | ||||||
|  | 	"github.com/gotosocial/gotosocial/internal/config" | ||||||
|  | 	"github.com/gotosocial/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/gotosocial/oauth2/v4" | ||||||
|  | 	"github.com/sirupsen/logrus" | ||||||
|  | 	"github.com/stretchr/testify/suite" | ||||||
|  | 	"golang.org/x/crypto/bcrypt" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type OauthTestSuite struct { | ||||||
|  | 	suite.Suite | ||||||
|  | 	tokenStore  oauth2.TokenStore | ||||||
|  | 	clientStore oauth2.ClientStore | ||||||
|  | 	conn        *pg.DB | ||||||
|  | 	testAccount *gtsmodel.Account | ||||||
|  | 	testUser    *gtsmodel.User | ||||||
|  | 	testClient  *oauthClient | ||||||
|  | 	config      *config.Config | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const () | ||||||
|  | 
 | ||||||
|  | // SetupSuite sets some variables on the suite that we can use as consts (more or less) throughout | ||||||
|  | func (suite *OauthTestSuite) SetupSuite() { | ||||||
|  | 	encryptedPassword, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.DefaultCost) | ||||||
|  | 	if err != nil { | ||||||
|  | 		logrus.Panicf("error encrypting user pass: %s", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	suite.testAccount = >smodel.Account{} | ||||||
|  | 	suite.testUser = >smodel.User{ | ||||||
|  | 		EncryptedPassword: string(encryptedPassword), | ||||||
|  | 		Email:             "user@localhost", | ||||||
|  | 		AccountID:         "some-account-id-it-doesn't-matter-really-since-this-user-doesn't-actually-have-an-account!", | ||||||
|  | 	} | ||||||
|  | 	suite.testClient = &oauthClient{ | ||||||
|  | 		ID:     "a-known-client-id", | ||||||
|  | 		Secret: "some-secret", | ||||||
|  | 		Domain: "http://localhost:8080", | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// because go tests are run within the test package directory, we need to fiddle with the templateconfig | ||||||
|  | 	// basedir in a way that we wouldn't normally have to do when running the binary, in order to make | ||||||
|  | 	// the templates actually load | ||||||
|  | 	c := config.Empty() | ||||||
|  | 	c.TemplateConfig.BaseDir = "../../web/template/" | ||||||
|  | 	suite.config = c | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // SetupTest creates a postgres connection and creates the oauth_clients table before each test | ||||||
|  | func (suite *OauthTestSuite) SetupTest() { | ||||||
|  | 	suite.conn = pg.Connect(&pg.Options{}) | ||||||
|  | 	if err := suite.conn.Ping(context.Background()); err != nil { | ||||||
|  | 		logrus.Panicf("db connection error: %s", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	models := []interface{}{ | ||||||
|  | 		&oauthClient{}, | ||||||
|  | 		&oauthToken{}, | ||||||
|  | 		>smodel.User{}, | ||||||
|  | 		>smodel.Account{}, | ||||||
|  | 		>smodel.Application{}, | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	for _, m := range models { | ||||||
|  | 		if err := suite.conn.Model(m).CreateTable(&orm.CreateTableOptions{ | ||||||
|  | 			IfNotExists: true, | ||||||
|  | 		}); err != nil { | ||||||
|  | 			logrus.Panicf("db connection error: %s", err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	suite.tokenStore = NewPGTokenStore(context.Background(), suite.conn, logrus.New()) | ||||||
|  | 	suite.clientStore = NewPGClientStore(suite.conn) | ||||||
|  | 
 | ||||||
|  | 	if _, err := suite.conn.Model(suite.testUser).Insert(); err != nil { | ||||||
|  | 		logrus.Panicf("could not insert test user into db: %s", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if _, err := suite.conn.Model(suite.testClient).Insert(); err != nil { | ||||||
|  | 		logrus.Panicf("could not insert test client into db: %s", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // TearDownTest drops the oauth_clients table and closes the pg connection after each test | ||||||
|  | func (suite *OauthTestSuite) TearDownTest() { | ||||||
|  | 	models := []interface{}{ | ||||||
|  | 		&oauthClient{}, | ||||||
|  | 		&oauthToken{}, | ||||||
|  | 		>smodel.User{}, | ||||||
|  | 		>smodel.Account{}, | ||||||
|  | 		>smodel.Application{}, | ||||||
|  | 	} | ||||||
|  | 	for _, m := range models { | ||||||
|  | 		if err := suite.conn.Model(m).DropTable(&orm.DropTableOptions{}); err != nil { | ||||||
|  | 			logrus.Panicf("drop table error: %s", err) | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 	if err := suite.conn.Close(); err != nil { | ||||||
|  | 		logrus.Panicf("error closing db connection: %s", err) | ||||||
|  | 	} | ||||||
|  | 	suite.conn = nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (suite *OauthTestSuite) TestAPIInitialize() { | ||||||
|  | 	log := logrus.New() | ||||||
|  | 	log.SetLevel(logrus.TraceLevel) | ||||||
|  | 
 | ||||||
|  | 	r := api.New(suite.config, log) | ||||||
|  | 	api := New(suite.tokenStore, suite.clientStore, suite.conn, log) | ||||||
|  | 	api.AddRoutes(r) | ||||||
|  | 	go r.Start() | ||||||
|  | 	time.Sleep(30 * time.Second) | ||||||
|  | 	// http://localhost:8080/oauth/authorize?client_id=a-known-client-id&response_type=code&redirect_uri=https://example.org | ||||||
|  | 	// http://localhost:8080/oauth/authorize?client_id=a-known-client-id&response_type=code&redirect_uri=urn:ietf:wg:oauth:2.0:oob | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestOauthTestSuite(t *testing.T) { | ||||||
|  | 	suite.Run(t, new(OauthTestSuite)) | ||||||
|  | } | ||||||
|  | @ -20,6 +20,7 @@ package oauth | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
|  | 	"fmt" | ||||||
| 
 | 
 | ||||||
| 	"github.com/go-pg/pg/v10" | 	"github.com/go-pg/pg/v10" | ||||||
| 	"github.com/gotosocial/oauth2/v4" | 	"github.com/gotosocial/oauth2/v4" | ||||||
|  | @ -42,7 +43,7 @@ func (pcs *pgClientStore) GetByID(ctx context.Context, clientID string) (oauth2. | ||||||
| 		ID: clientID, | 		ID: clientID, | ||||||
| 	} | 	} | ||||||
| 	if err := pcs.conn.WithContext(ctx).Model(poc).Where("id = ?", poc.ID).Select(); err != nil { | 	if err := pcs.conn.WithContext(ctx).Model(poc).Where("id = ?", poc.ID).Select(); err != nil { | ||||||
| 		return nil, err | 		return nil, fmt.Errorf("error in clientstore getbyid searching for client %s: %s", clientID, err) | ||||||
| 	} | 	} | ||||||
| 	return models.New(poc.ID, poc.Secret, poc.Domain, poc.UserID), nil | 	return models.New(poc.ID, poc.Secret, poc.Domain, poc.UserID), nil | ||||||
| } | } | ||||||
|  | @ -55,7 +56,10 @@ func (pcs *pgClientStore) Set(ctx context.Context, id string, cli oauth2.ClientI | ||||||
| 		UserID: cli.GetUserID(), | 		UserID: cli.GetUserID(), | ||||||
| 	} | 	} | ||||||
| 	_, err := pcs.conn.WithContext(ctx).Model(poc).OnConflict("(id) DO UPDATE").Insert() | 	_, err := pcs.conn.WithContext(ctx).Model(poc).OnConflict("(id) DO UPDATE").Insert() | ||||||
| 	return err | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("error in clientstore set: %s", err) | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (pcs *pgClientStore) Delete(ctx context.Context, id string) error { | func (pcs *pgClientStore) Delete(ctx context.Context, id string) error { | ||||||
|  | @ -63,7 +67,10 @@ func (pcs *pgClientStore) Delete(ctx context.Context, id string) error { | ||||||
| 		ID: id, | 		ID: id, | ||||||
| 	} | 	} | ||||||
| 	_, err := pcs.conn.WithContext(ctx).Model(poc).Where("id = ?", poc.ID).Delete() | 	_, err := pcs.conn.WithContext(ctx).Model(poc).Where("id = ?", poc.ID).Delete() | ||||||
| 	return err | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("error in clientstore delete: %s", err) | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type oauthClient struct { | type oauthClient struct { | ||||||
|  |  | ||||||
|  | @ -96,7 +96,6 @@ func (suite *PgClientStoreTestSuite) TestClientSetAndDelete() { | ||||||
| 	deletedClient, err := cs.GetByID(context.Background(), suite.testClientID) | 	deletedClient, err := cs.GetByID(context.Background(), suite.testClientID) | ||||||
| 	suite.Assert().Nil(deletedClient) | 	suite.Assert().Nil(deletedClient) | ||||||
| 	suite.Assert().NotNil(err) | 	suite.Assert().NotNil(err) | ||||||
| 	suite.EqualValues("pg: no rows in result set", err.Error()) |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func TestPgClientStoreTestSuite(t *testing.T) { | func TestPgClientStoreTestSuite(t *testing.T) { | ||||||
|  |  | ||||||
|  | @ -21,6 +21,7 @@ package oauth | ||||||
| import ( | import ( | ||||||
| 	"context" | 	"context" | ||||||
| 	"errors" | 	"errors" | ||||||
|  | 	"fmt" | ||||||
| 	"time" | 	"time" | ||||||
| 
 | 
 | ||||||
| 	"github.com/go-pg/pg/v10" | 	"github.com/go-pg/pg/v10" | ||||||
|  | @ -98,32 +99,44 @@ func (pts *pgTokenStore) Create(ctx context.Context, info oauth2.TokenInfo) erro | ||||||
| 		return errors.New("info param was not a models.Token") | 		return errors.New("info param was not a models.Token") | ||||||
| 	} | 	} | ||||||
| 	_, err := pts.conn.WithContext(ctx).Model(oauthTokenToPGToken(t)).Insert() | 	_, err := pts.conn.WithContext(ctx).Model(oauthTokenToPGToken(t)).Insert() | ||||||
| 	return err | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("error in tokenstore create: %s", err) | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // RemoveByCode deletes a token from the DB based on the Code field | // RemoveByCode deletes a token from the DB based on the Code field | ||||||
| func (pts *pgTokenStore) RemoveByCode(ctx context.Context, code string) error { | func (pts *pgTokenStore) RemoveByCode(ctx context.Context, code string) error { | ||||||
| 	_, err := pts.conn.Model(&oauthToken{}).Where("code = ?", code).Delete() | 	_, err := pts.conn.Model(&oauthToken{}).Where("code = ?", code).Delete() | ||||||
| 	return err | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("error in tokenstore removebycode: %s", err) | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // RemoveByAccess deletes a token from the DB based on the Access field | // RemoveByAccess deletes a token from the DB based on the Access field | ||||||
| func (pts *pgTokenStore) RemoveByAccess(ctx context.Context, access string) error { | func (pts *pgTokenStore) RemoveByAccess(ctx context.Context, access string) error { | ||||||
| 	_, err := pts.conn.Model(&oauthToken{}).Where("access = ?", access).Delete() | 	_, err := pts.conn.Model(&oauthToken{}).Where("access = ?", access).Delete() | ||||||
| 	return err | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("error in tokenstore removebyaccess: %s", err) | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // RemoveByRefresh deletes a token from the DB based on the Refresh field | // RemoveByRefresh deletes a token from the DB based on the Refresh field | ||||||
| func (pts *pgTokenStore) RemoveByRefresh(ctx context.Context, refresh string) error { | func (pts *pgTokenStore) RemoveByRefresh(ctx context.Context, refresh string) error { | ||||||
| 	_, err := pts.conn.Model(&oauthToken{}).Where("refresh = ?", refresh).Delete() | 	_, err := pts.conn.Model(&oauthToken{}).Where("refresh = ?", refresh).Delete() | ||||||
| 	return err | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("error in tokenstore removebyrefresh: %s", err) | ||||||
|  | 	} | ||||||
|  | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| // GetByCode selects a token from the DB based on the Code field | // GetByCode selects a token from the DB based on the Code field | ||||||
| func (pts *pgTokenStore) GetByCode(ctx context.Context, code string) (oauth2.TokenInfo, error) { | func (pts *pgTokenStore) GetByCode(ctx context.Context, code string) (oauth2.TokenInfo, error) { | ||||||
| 	pgt := &oauthToken{} | 	pgt := &oauthToken{} | ||||||
| 	if err := pts.conn.Model(pgt).Where("code = ?", code).Select(); err != nil { | 	if err := pts.conn.Model(pgt).Where("code = ?", code).Select(); err != nil { | ||||||
| 		return nil, err | 		return nil, fmt.Errorf("error in tokenstore getbycode: %s", err) | ||||||
| 	} | 	} | ||||||
| 	return pgTokenToOauthToken(pgt), nil | 	return pgTokenToOauthToken(pgt), nil | ||||||
| } | } | ||||||
|  | @ -132,7 +145,7 @@ func (pts *pgTokenStore) GetByCode(ctx context.Context, code string) (oauth2.Tok | ||||||
| func (pts *pgTokenStore) GetByAccess(ctx context.Context, access string) (oauth2.TokenInfo, error) { | func (pts *pgTokenStore) GetByAccess(ctx context.Context, access string) (oauth2.TokenInfo, error) { | ||||||
| 	pgt := &oauthToken{} | 	pgt := &oauthToken{} | ||||||
| 	if err := pts.conn.Model(pgt).Where("access = ?", access).Select(); err != nil { | 	if err := pts.conn.Model(pgt).Where("access = ?", access).Select(); err != nil { | ||||||
| 		return nil, err | 		return nil, fmt.Errorf("error in tokenstore getbyaccess: %s", err) | ||||||
| 	} | 	} | ||||||
| 	return pgTokenToOauthToken(pgt), nil | 	return pgTokenToOauthToken(pgt), nil | ||||||
| } | } | ||||||
|  | @ -141,7 +154,7 @@ func (pts *pgTokenStore) GetByAccess(ctx context.Context, access string) (oauth2 | ||||||
| func (pts *pgTokenStore) GetByRefresh(ctx context.Context, refresh string) (oauth2.TokenInfo, error) { | func (pts *pgTokenStore) GetByRefresh(ctx context.Context, refresh string) (oauth2.TokenInfo, error) { | ||||||
| 	pgt := &oauthToken{} | 	pgt := &oauthToken{} | ||||||
| 	if err := pts.conn.Model(pgt).Where("refresh = ?", refresh).Select(); err != nil { | 	if err := pts.conn.Model(pgt).Where("refresh = ?", refresh).Select(); err != nil { | ||||||
| 		return nil, err | 		return nil, fmt.Errorf("error in tokenstore getbyrefresh: %s", err) | ||||||
| 	} | 	} | ||||||
| 	return pgTokenToOauthToken(pgt), nil | 	return pgTokenToOauthToken(pgt), nil | ||||||
| } | } | ||||||
|  | @ -165,15 +178,15 @@ type oauthToken struct { | ||||||
| 	UserID              string | 	UserID              string | ||||||
| 	RedirectURI         string | 	RedirectURI         string | ||||||
| 	Scope               string | 	Scope               string | ||||||
| 	Code                string `pg:",pk"` | 	Code                string `pg:"default:'',pk"` | ||||||
| 	CodeChallenge       string | 	CodeChallenge       string | ||||||
| 	CodeChallengeMethod string | 	CodeChallengeMethod string | ||||||
| 	CodeCreateAt        time.Time `pg:"type:timestamp"` | 	CodeCreateAt        time.Time `pg:"type:timestamp"` | ||||||
| 	CodeExpiresAt       time.Time `pg:"type:timestamp"` | 	CodeExpiresAt       time.Time `pg:"type:timestamp"` | ||||||
| 	Access              string    `pg:",pk"` | 	Access              string    `pg:"default:'',pk"` | ||||||
| 	AccessCreateAt      time.Time `pg:"type:timestamp"` | 	AccessCreateAt      time.Time `pg:"type:timestamp"` | ||||||
| 	AccessExpiresAt     time.Time `pg:"type:timestamp"` | 	AccessExpiresAt     time.Time `pg:"type:timestamp"` | ||||||
| 	Refresh             string    `pg:",pk"` | 	Refresh             string    `pg:"default:'',pk"` | ||||||
| 	RefreshCreateAt     time.Time `pg:"type:timestamp"` | 	RefreshCreateAt     time.Time `pg:"type:timestamp"` | ||||||
| 	RefreshExpiresAt    time.Time `pg:"type:timestamp"` | 	RefreshExpiresAt    time.Time `pg:"type:timestamp"` | ||||||
| } | } | ||||||
|  |  | ||||||
|  | @ -18,12 +18,38 @@ | ||||||
| 
 | 
 | ||||||
| package mastotypes | package mastotypes | ||||||
| 
 | 
 | ||||||
| // Application represents a mastodon-api Application, as defined here: https://docs.joinmastodon.org/entities/application/ | // Application represents a mastodon-api Application, as defined here: https://docs.joinmastodon.org/entities/application/. | ||||||
|  | // Primarily, application is used for allowing apps like Tusky etc to connect to Mastodon on behalf of a user. | ||||||
|  | // See https://docs.joinmastodon.org/methods/apps/ | ||||||
| type Application struct { | type Application struct { | ||||||
|  | 	// The application ID in the db | ||||||
|  | 	ID string `json:"id,omitempty"` | ||||||
| 	// The name of your application. | 	// The name of your application. | ||||||
| 	Name string `json:"name"` | 	Name string `json:"name"` | ||||||
| 	// The website associated with your application (url) | 	// The website associated with your application (url) | ||||||
| 	Website string `json:"website"` | 	Website string `json:"website,omitempty"` | ||||||
|  | 	// Where the user should be redirected after authorization. | ||||||
|  | 	RedirectURI string `json:"redirect_uri,omitempty"` | ||||||
|  | 	// ClientID to use when obtaining an oauth token for this application (ie., in client_id parameter of https://docs.joinmastodon.org/methods/apps/) | ||||||
|  | 	ClientID string `json:"client_id,omitempty"` | ||||||
|  | 	// Client secret to use when obtaining an auth token for this application (ie., in client_secret parameter of https://docs.joinmastodon.org/methods/apps/) | ||||||
|  | 	ClientSecret string `json:"client_secret,omitempty"` | ||||||
| 	// Used for Push Streaming API. Returned with POST /api/v1/apps. Equivalent to https://docs.joinmastodon.org/entities/pushsubscription/#server_key | 	// Used for Push Streaming API. Returned with POST /api/v1/apps. Equivalent to https://docs.joinmastodon.org/entities/pushsubscription/#server_key | ||||||
| 	VapidKey string `json:"vapid_key"` | 	VapidKey string `json:"vapid_key"` | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // ApplicationPOSTRequest represents a POST request to https://example.org/api/v1/apps. | ||||||
|  | // See here: https://docs.joinmastodon.org/methods/apps/ | ||||||
|  | // And here: https://docs.joinmastodon.org/client/token/ | ||||||
|  | type ApplicationPOSTRequest struct { | ||||||
|  | 	// A name for your application | ||||||
|  | 	ClientName string `form:"client_name"` | ||||||
|  | 	// Where the user should be redirected after authorization. | ||||||
|  | 	// To display the authorization code to the user instead of redirecting | ||||||
|  | 	// to a web page, use urn:ietf:wg:oauth:2.0:oob in this parameter. | ||||||
|  | 	RedirectURIs string `form:"redirect_uris"` | ||||||
|  | 	// Space separated list of scopes. If none is provided, defaults to read. | ||||||
|  | 	Scopes string `form:"scopes"` | ||||||
|  | 	// A URL to the homepage of your app | ||||||
|  | 	Website string `form:"website"` | ||||||
|  | } | ||||||
|  |  | ||||||
							
								
								
									
										37
									
								
								pkg/mastotypes/oauth.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								pkg/mastotypes/oauth.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,37 @@ | ||||||
|  | /* | ||||||
|  |    GoToSocial | ||||||
|  |    Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org | ||||||
|  | 
 | ||||||
|  |    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 mastotypes | ||||||
|  | 
 | ||||||
|  | // OAuthAuthorize represents a request sent to https://example.org/oauth/authorize | ||||||
|  | // See here: https://docs.joinmastodon.org/methods/apps/oauth/ | ||||||
|  | type OAuthAuthorize struct { | ||||||
|  | 	// Forces the user to re-login, which is necessary for authorizing with multiple accounts from the same instance. | ||||||
|  | 	ForceLogin string `form:"force_login,omitempty"` | ||||||
|  | 	// Should be set equal to `code`. | ||||||
|  | 	ResponseType string `form:"response_type"` | ||||||
|  | 	// Client ID, obtained during app registration. | ||||||
|  | 	ClientID string `form:"client_id"` | ||||||
|  | 	// Set a URI to redirect the user to. | ||||||
|  | 	// If this parameter is set to urn:ietf:wg:oauth:2.0:oob then the authorization code will be shown instead. | ||||||
|  | 	// Must match one of the redirect URIs declared during app registration. | ||||||
|  | 	RedirectURI string `form:"redirect_uri"` | ||||||
|  | 	// List of requested OAuth scopes, separated by spaces (or by pluses, if using query parameters). | ||||||
|  | 	// Must be a subset of scopes declared during app registration. If not provided, defaults to read. | ||||||
|  | 	Scope string `form:"scope,omitempty"` | ||||||
|  | } | ||||||
							
								
								
									
										44
									
								
								web/template/authorize.tmpl
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								web/template/authorize.tmpl
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,44 @@ | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en"> | ||||||
|  |   <head> | ||||||
|  |     <meta charset="UTF-8" /> | ||||||
|  |     <title>Auth</title> | ||||||
|  |     <link | ||||||
|  |       rel="stylesheet" | ||||||
|  |       href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css" | ||||||
|  |     /> | ||||||
|  |     <script src="//code.jquery.com/jquery-2.2.4.min.js"></script> | ||||||
|  |     <script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js"></script> | ||||||
|  |   </head> | ||||||
|  | 
 | ||||||
|  | {{if len .code | eq 0 }} | ||||||
|  |   <body> | ||||||
|  |     <div class="container"> | ||||||
|  |       <div class="jumbotron"> | ||||||
|  |         <form action="/oauth/authorize" method="POST"> | ||||||
|  |           <h1>Authorize</h1> | ||||||
|  |           <p>The client would like to perform actions on your behalf.</p> | ||||||
|  |           <p> | ||||||
|  |             <button | ||||||
|  |               type="submit" | ||||||
|  |               class="btn btn-primary btn-lg" | ||||||
|  |               style="width:200px;" | ||||||
|  |             > | ||||||
|  |               Allow | ||||||
|  |             </button> | ||||||
|  |           </p> | ||||||
|  |         </form> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </body> | ||||||
|  | {{else}} | ||||||
|  |   <body> | ||||||
|  |     <div class="container"> | ||||||
|  |       <div class="jumbotron"> | ||||||
|  |         {{.code}} | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </body> | ||||||
|  | {{end}} | ||||||
|  | 
 | ||||||
|  | </html> | ||||||
							
								
								
									
										28
									
								
								web/template/sign-in.tmpl
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								web/template/sign-in.tmpl
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,28 @@ | ||||||
|  | <!DOCTYPE html> | ||||||
|  | <html lang="en"> | ||||||
|  | <head> | ||||||
|  |     <meta charset="UTF-8"> | ||||||
|  |     <title>Login</title> | ||||||
|  |     <link rel="stylesheet" href="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css"> | ||||||
|  |     <script src="//code.jquery.com/jquery-2.2.4.min.js"></script> | ||||||
|  |     <script src="//maxcdn.bootstrapcdn.com/bootstrap/3.3.6/js/bootstrap.min.js"></script> | ||||||
|  | </head> | ||||||
|  | 
 | ||||||
|  | <body> | ||||||
|  |     <div class="container"> | ||||||
|  |         <h1>Login</h1> | ||||||
|  |         <form action="/auth/sign_in" method="POST"> | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="email">Email</label> | ||||||
|  |                 <input type="text" class="form-control" name="username" required placeholder="Please enter your email address"> | ||||||
|  |             </div> | ||||||
|  |             <div class="form-group"> | ||||||
|  |                 <label for="password">Password</label> | ||||||
|  |                 <input type="password" class="form-control" name="password" placeholder="Please enter your password"> | ||||||
|  |             </div> | ||||||
|  |             <button type="submit" class="btn btn-success">Login</button> | ||||||
|  |         </form> | ||||||
|  |     </div> | ||||||
|  | </body> | ||||||
|  | 
 | ||||||
|  | </html> | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue