diff --git a/internal/profile/repository.go b/internal/profile/repository.go new file mode 100644 index 0000000..9b89398 --- /dev/null +++ b/internal/profile/repository.go @@ -0,0 +1,15 @@ +package profile + +import ( + "context" + + "golang.org/x/oauth2" + + "source.toby3d.me/website/indieauth/internal/domain" +) + +type Repository interface { + Get(ctx context.Context, token *oauth2.Token) (*domain.Profile, error) +} + +var ErrNotExist error = domain.NewError(domain.ErrorCodeServerError, "not found link back to provided Me", "") diff --git a/internal/profile/repository/github/github_profile.go b/internal/profile/repository/github/github_profile.go new file mode 100644 index 0000000..b8e9865 --- /dev/null +++ b/internal/profile/repository/github/github_profile.go @@ -0,0 +1,76 @@ +package github + +import ( + "context" + "fmt" + + github "github.com/google/go-github/v41/github" + "golang.org/x/oauth2" + + "source.toby3d.me/website/indieauth/internal/domain" + "source.toby3d.me/website/indieauth/internal/profile" +) + +type githubProfileRepository struct{} + +const ErrPrefix string = "github" + +func NewGithubProfileRepository() profile.Repository { + return &githubProfileRepository{} +} + +func (repo *githubProfileRepository) Get(ctx context.Context, token *oauth2.Token) (*domain.Profile, error) { + user, _, err := github.NewClient(oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))).Users.Get(ctx, "") + if err != nil { + return nil, fmt.Errorf("%s: cannot get user info: %w", ErrPrefix, err) + } + + result := new(domain.Profile) + + // NOTE(toby3d): Profile names. + if user.Name != nil { + result.Name = []string{*user.Name} + } + + // NOTE(toby3d): Profile photos. + if user.AvatarURL != nil { + if u, err := domain.ParseURL(*user.AvatarURL); err == nil { + result.Photo = []*domain.URL{u} + } + } + + // NOTE(toby3d): Profile URLs. + result.URL = make([]*domain.URL, 0) + var twitterURL *string + + if user.TwitterUsername != nil { + u := "https://twitter.com/" + *user.TwitterUsername + twitterURL = &u + } + + for _, src := range []*string{ + user.HTMLURL, // NOTE(toby3d): always available. + user.Blog, + twitterURL, + } { + if src == nil { + continue + } + + u, err := domain.ParseURL(*src) + if err != nil { + continue + } + + result.URL = append(result.URL, u) + } + + // NOTE(toby3d): Profile Emails. + if user.Email != nil { + if email, err := domain.ParseEmail(*user.Email); err == nil { + result.Email = []*domain.Email{email} + } + } + + return result, nil +} diff --git a/internal/profile/repository/gitlab/gitlab_profile.go b/internal/profile/repository/gitlab/gitlab_profile.go new file mode 100644 index 0000000..7dad014 --- /dev/null +++ b/internal/profile/repository/gitlab/gitlab_profile.go @@ -0,0 +1,90 @@ +package gitlab + +import ( + "context" + "fmt" + + gitlab "github.com/xanzy/go-gitlab" + "golang.org/x/oauth2" + + "source.toby3d.me/website/indieauth/internal/domain" + "source.toby3d.me/website/indieauth/internal/profile" +) + +type gitlabProfileRepository struct{} + +const ErrPrefix string = "gitlab" + +func NewGitlabProfileRepository() profile.Repository { + return &gitlabProfileRepository{} +} + +//nolint: funlen // NOTE(toby3d): uses hyphenation on new lines for readability. +func (repo *gitlabProfileRepository) Get(_ context.Context, token *oauth2.Token) (*domain.Profile, error) { + client, err := gitlab.NewClient(token.AccessToken) + if err != nil { + return nil, fmt.Errorf("%s: cannot create client: %w", ErrPrefix, err) + } + + user, _, err := client.Users.CurrentUser() + if err != nil { + return nil, fmt.Errorf("%s: cannot get user info: %w", ErrPrefix, err) + } + + result := new(domain.Profile) + + // NOTE(toby3d): Profile names. + if user.Name != "" { + result.Name = []string{user.Name} + } + + // NOTE(toby3d): Profile photos. + if user.AvatarURL != "" { + if u, err := domain.ParseURL(user.AvatarURL); err == nil { + result.Photo = []*domain.URL{u} + } + } + + // NOTE(toby3d): Profile URLs. + result.URL = make([]*domain.URL, 0) + + for _, src := range []string{ + user.WebURL, // NOTE(toby3d): always available. + user.WebsiteURL, + "https://twitter.com/" + user.Twitter, + // TODO(toby3d): Skype field + // TODO(toby3d): LinkedIn field + } { + if src == "" || src == "https://twitter.com/" { + continue + } + + u, err := domain.ParseURL(user.WebsiteURL) + if err != nil { + continue + } + + result.URL = append(result.URL, u) + } + + // NOTE(toby3d): Profile Emails. + result.Email = make([]*domain.Email, 0) + + for _, src := range []string{ + user.PublicEmail, + user.Email, + } { + if src == "" { + continue + } + + email, err := domain.ParseEmail(src) + if err != nil { + continue + } + + result.Email = append(result.Email, email) + } + + return result, nil +} diff --git a/internal/profile/repository/mastodon/mastodon_profile.go b/internal/profile/repository/mastodon/mastodon_profile.go new file mode 100644 index 0000000..3fc9e16 --- /dev/null +++ b/internal/profile/repository/mastodon/mastodon_profile.go @@ -0,0 +1,77 @@ +package mastodon + +import ( + "context" + "fmt" + + mastodon "github.com/mattn/go-mastodon" + "golang.org/x/oauth2" + + "source.toby3d.me/website/indieauth/internal/domain" + "source.toby3d.me/website/indieauth/internal/profile" +) + +type mastodonProfileRepository struct { + server string +} + +const ErrPrefix string = "mastodon" + +func NewMastodonProfileRepository(server string) profile.Repository { + return &mastodonProfileRepository{ + server: server, + } +} + +func (repo *mastodonProfileRepository) Get(ctx context.Context, token *oauth2.Token) (*domain.Profile, error) { + account, err := mastodon.NewClient(&mastodon.Config{ + Server: repo.server, + AccessToken: token.AccessToken, + }).GetAccountCurrentUser(ctx) + if err != nil { + return nil, fmt.Errorf("%s: cannot get account info: %w", ErrPrefix, err) + } + + result := new(domain.Profile) + + // NOTE(toby3d): Profile names. + if account.DisplayName != "" { + result.Name = []string{account.DisplayName} + } + + // NOTE(toby3d): Profile photos. + if account.Avatar != "" { + if u, err := domain.ParseURL(account.Avatar); err == nil { + result.Photo = []*domain.URL{u} + } + } + + // NOTE(toby3d): Profile URLs. + result.URL = make([]*domain.URL, 0) + + // NOTE(toby3d): must be always available + if account.URL != "" { + if u, err := domain.ParseURL(account.URL); err == nil { + result.URL = append(result.URL, u) + } + } + + for i := range account.Fields { + // NOTE(toby3d): ignore non-verified fields that contain either + // free-form text or links in them have not yet been verified. + if account.Fields[i].VerifiedAt.IsZero() { + continue + } + + u, err := domain.ParseURL(account.Fields[i].Value) + if err != nil { + continue + } + + result.URL = append(result.URL, u) + } + + // WARN(toby3d): Mastodon does not provide an email via API. + + return result, nil +} diff --git a/internal/profile/repository/memory/memory_profile.go b/internal/profile/repository/memory/memory_profile.go new file mode 100644 index 0000000..78d19b7 --- /dev/null +++ b/internal/profile/repository/memory/memory_profile.go @@ -0,0 +1,42 @@ +package memory + +import ( + "context" + "fmt" + "path" + "sync" + + "golang.org/x/oauth2" + + "source.toby3d.me/website/indieauth/internal/domain" + "source.toby3d.me/website/indieauth/internal/profile" +) + +type memoryProfileRepository struct { + store *sync.Map +} + +const ( + ErrPrefix string = "memory" + DefaultPathPrefix string = "profiles" +) + +func NewMemoryProfileRepository(store *sync.Map) profile.Repository { + return &memoryProfileRepository{ + store: store, + } +} + +func (repo *memoryProfileRepository) Get(_ context.Context, token *oauth2.Token) (*domain.Profile, error) { + src, ok := repo.store.Load(path.Join(DefaultPathPrefix, token.AccessToken)) + if !ok { + return nil, fmt.Errorf("%s: cannot find profile in store: %w", ErrPrefix, profile.ErrNotExist) + } + + result, ok := src.(*domain.Profile) + if !ok { + return nil, fmt.Errorf("%s: cannot decode profile from store: %w", ErrPrefix, profile.ErrNotExist) + } + + return result, nil +}