gocial icon indicating copy to clipboard operation
gocial copied to clipboard

Social media interactions - The Gopher way

#+EXPORT_FILE_NAME: readme.html

#+caption: Screnshot from December 2022 [[file:docs/images/2022-12-screenshot.png]]

  • About TL;DR Share articles and comments via different social media platforms.

Some while ago I was heavily using services like buffer, zapier and ifttt to automatically share interesting articles on social media. All sharing services had great functionalities (e.g. automated workflows) but you're always limited in the number of shares you can distribute within a time frame without paying for the premium account. At the same time they all lacked support for sharing via LinkedIn. I had a brief look at the LinkedIn API documentation and decided I'll implement my own service using Golang for the backend.

Here is the full [[https://gocial.netlify.app][DEMO]].

  • Features
  • Architecture
    • Serverless
      • Currently I use [[https://www.netlify.com/][netlify.com]] to serve my Golang binary as a [[https://docs.netlify.com/functions/build/?fn-language=go][netlify build function]].
    • Hexagonal Architecture (also known as Ports & Adapters)
      • I /try/ to encapsulate business logic in its own domain and have clear boundaries between my components (also see [[*Architecture][Architecture]])
      • I started working on ~gocial~ long before I have released my presentation on [[https://slides.dornea.nu/2022/hexagonal-architecture/][Hexagonal Architecture]] (in Python).
  • Backend
    • Obvisouly, I use Golang as a core language 😎
    • I use the [[https://echo.labstack.com/][echo]] web framework for implementing most of the HTTP stuff (server, REST API, OAuth workflows)
    • for the OAuth part I've mainly used [[https://github.com/markbates/goth][goth]] to do the authentication via the /identity providers/
    • I use /stateless authentication/ and no authentication data (like access tokens) is stored server-side
      • I use /JWT tokens/ for /authorization/ and /secure/, /httpOnly/ cookies as storage mechanism
      • I don't use /localStorage/ nor /sessionStorage/ since in the case of XSS, an attacker could easily access the tokens.
  • Frontend
    • Initially I've implemented the frontend in /Vue.js/ but I switched over to
      • [[https://pkg.go.dev/html/template][Golang HTML templates]] (server side rendering) and
      • [[https://tailwindcss.com/][TailwindCSS]] (for styling) and
      • [[https://alpinejs.dev/][Alpine.js]] (modern jQuery)
        • syntax very similar to /Vue.js/
        • also very powerful without the ~npm~ headaches
    • I really like the /TailwindCSS/ and /Alpine.js/ combo as it's quite minimalistic and feature-rich at the same time
    • I do plan to migrate to /Vue.js/ in the future
  • Architecture #+begin_src plantuml :file docs/images/architecture.png :results file replace :cmdline -charset UTF-8 :exports none :eval never-export

@startuml 'skinparam dpi 300 scale 1600 width skinparam nodesep 20

'top to bottom direction left to right direction

skinparam SameClassWidth true

package "Business Domain(s) ❶" as app_core { package Entities { entity "entity.IdentityProvider" as IdentityProvider { // Stores information about identity providers (e.g. Twitter) string Provider string UserName string UserID string UserDescription string UserAvatarURL string AccessToken string AccessTokenSecret string RefreshToken time.Time ExpiresAt } entity "entity.ArticleShare" as ArticleShare { string URL string Title string Comment string Providers // Holds information about an article to be shared

    }
    entity "entity.CommentShare" as CommentShare {
        string Comment
        // Holds information about a comment to be shared

    }
    entity "entity.AuthProviderIndex" as AuthProviderIndex {
        []string Providers
        map[string]string ProvidersMap
        // Holds list of available (identity) providers
    }
}

package Identity {
    interface identityRepository as "identity.Repository" {
        Storage for available identities
        + Add (entity.IdentityProvider, echo.Context) error
        + GetByProvider(string, echo.Context) (entity.IdentityProvider, error)
        + Delete (string, echo.Context) error
        + Save() error
        + Load() error
    }
}

package OAuth {
    interface oauthRepository as "oauth.Repository" {
        Authentication handler
        + HandleAuth (echo.Context) error
        + HandleAuthCallback (echo.Context) error
    }
    class oauthService as "oauth.Service" {
        + Repo: oauth.Repository
        + ProviderIndex: entity.AuthProviderIndex
    }
}

package Share {
    interface shareRepository as "share.Repository" {
        Defines how an article should be shared
        + ShareArticle(context.Context, entity.ArticleShare) error
    }

    class shareService as "share.Service" {
        + ShareArticle(entity.ArticleShare, share.Repository) error
        + ShareComment(entity.CommentShare, share.Repository) error
        + GetShareRepo(entity.IdentityProvider) (share.Repository, error)
    }
}

}

package "Services ❷" as services { class HTTPServer { // Exposes API and functionalities via HTTP }

class Lambda {
    // Exposes functionalities in a Serverless environment
}

' class CLICommand as "CLI" {
' '    // Interact with gocial via CLI
' '}

}

package "OAuth Repositories" as oauthRepoImpl { class GothRepository { Handles OAuth workflow between gocial and identity providers\nusing 3rd-party library called goth. } }

package "Identity Repositories" as identityRepoImpl { class CookieIdentityRepository { Reads, stores and handles authentication data via cookies.\nJWT tokens are used and stored as secure and httpOnly cookies. }

class FileIdentityRepository {
    Reads, stores and handles authentication data via files
}

}

package "Share Repositories" as shareRepoImpl { class LinkedinShareRepository { Shares articles via LinkedIn }

class TwitterShareRepository  {
    Shares articles via Twitter
}

}

' ----------- Connections HTTPServer -up-> oauthService: uses HTTPServer -up-> shareService : uses HTTPServer -up-> identityRepository : uses Lambda ---left---> HTTPServer: uses

'CLICommand --> oauthService: uses

GothRepository ..> oauthRepository: implements LinkedinShareRepository ..> shareRepository: implements TwitterShareRepository ..> shareRepository: implements

CookieIdentityRepository ..> identityRepository: implements FileIdentityRepository ..> identityRepository: implements

' ----------- Alignment ' All entities below each other IdentityProvider -[hidden]left- ArticleShare ArticleShare -[hidden]left- AuthProviderIndex CommentShare -[hidden]left- AuthProviderIndex

' Services right of entities Entities -[hidden]up- services

@enduml #+end_src

#+caption: Overal software architecture [[file:docs/images/architecture.png]]

** Business domain

  • everything related to the business case
    • user wants to allow /gocial/ to make posts and his/her behalf
    • user can share articles/comments to multiple social media platforms
  • contains
    • /Entities/
    • Different /other/ domains related to the business case
      • each one might contain
        • /Services/
        • /Repositories/Interfaces/ ** Identity An /identity/ is something you get after successful authentication. After allowing /gocial/ to interact with Twitter/LinkedIn this /struct/ will be used to hold information about an /identity provider/:

#+caption: Structure for an identity provider #+begin_src go type IdentityProvider struct { Provider string yaml:"provider" UserName string yaml:"name" UserID string yaml:"id" UserDescription string yaml:"description" UserAvatarURL string yaml:"userAvatarURL" AccessToken string yaml:"accessToken" AccessTokenSecret string yaml:"accessTokenSecret" RefreshToken string yaml:"refreshToken" ExpiresAt *time.Time yaml:"expiry" } #+end_src ** OAuth The /oauth/ package uses [[https://github.com/markbates/goth][goth]] to implement the OAuth workflow. /goth/ basically implements this interface:

#+caption: The oauth.Repository interface #+begin_src go type Repository interface { HandleAuth(echo.Context) error HandleAuthCallback(echo.Context) error } #+end_src

  • ~HandleAuth~ defines how authentication should be done for different identity providers
  • ~HandleAuthCallback~ is a /callback/ called by the identity providers
    • this is where the access tokens (among additional data) are sent to ** Share A /share/ is the most basic entity used within /gocial/. A ~Share~ is something that will be shared via different identity providers. At the moment you can share
  • an article
    • contains an URL, a comment, a title and a list of providers where the article should be shared to
  • a comment
    • not implemented yet

#+begin_src go // ArticleShare is an article to be shared via the share service type ArticleShare struct { URL string json:"url" form:"url" validate:"required" Title string json:"title" form:"title" validate:"required" Comment string json:"comment" form:"comment" validate:"required" Providers string json:"providers" form:"providers" validate:"required" }

// CommentShare is a comment to be shared via the share service type CommentShare struct { // TODO: Any other fields needed? Comment string } #+end_src

  • Project layout

#+begin_src sh :results output :exports results :eval never-export tree -L 1 -d . #+end_src

#+caption: Overall project layout #+RESULTS: #+begin_example gocial:

├── cli ├── docs ├── internal ├── lambda └── server #+end_example

** ~/internal~ This is where the /gocial/ specific domain code goes to. This includes /entities/, different /services/ and the /authentication/ part.

#+begin_src sh :results output :exports results :eval never-export tree -L 2 ./internal #+end_src

#+RESULTS: #+begin_example ./internal ├── config │   └── config.go ├── entity │   ├── identity.go │   ├── providers.go │   └── share.go ├── identity │   ├── cookie_repository.go │   ├── file_repository.go │   └── repository.go ├── jwt │   └── token.go ├── oauth │   ├── goth_repository.go │   ├── repository.go │   └── service.go └── share ├── linkedin_repository.go ├── repository.go ├── service.go └── twitter_repository.go #+end_example ** ~/server~ #+begin_src sh :results output :exports results :eval never-export tree -L 3 ./server #+end_src

#+RESULTS: #+begin_example ./server ├── api.go ├── html │   ├── html.go │   ├── package.json │   ├── package-lock.json │   ├── postcss.config.js │   ├── static │   │   └── main.css │   ├── tailwind.config.js │   ├── tailwind.css │   ├── tailwind.js │   └── templates │   ├── about.html │   ├── auth │   ├── base.html │   ├── index.html │   ├── partials │   └── share ├── http.go ├── oauth.go └── share.go #+end_example

This folder contains HTTP server specific functionalities:

  • ~/html~
    • here I put all the HTML templates and components (partials)
    • I use [[https://tailwindcss.com/][tailwindCSS]] so there is a little bit of ~npm~ foo
  • ~http.go~
    • responsible for launching the HTTP server and setting up API routes
    • renders HTML templates
  • ~api.go~
    • handles different API routes (e.g. sharing articles/comments)
  • ~oauth.go~
    • defines API endpoints for doing OAuth ** ~/cli~ Provides all ~gocial~ functionalities via a CLI tool. ** ~/lambda~ Runs the HTTP server as a Lambda function (hosted at [[https://www.netlify.com/][netlify.com]]).
  • Run it You can of course run it locally. However you'll need to create Twitter and LinkedIn accordingly. Then you'll need to set following /environment variables/ (in ~.env~ in the same folder): #+begin_src sh export LINKEDIN_CLIENT_ID=xxx export LINKEDIN_CLIENT_SECRET=xxx

export TWITTER_CLIENT_KEY=xxx export TWITTER_CLIENT_SECRET=xxx export TWITTER_ACCESS_TOKEN=xxx export TWITTER_ACCESS_SECRET=xxx #+end_src

Then you run ~make~ #+begin_src sh $ make build $ ./gocial --help NAME: gocial - A new cli application

USAGE: gocial [global options] command [command options] [arguments...]

VERSION: v0.1

AUTHOR: Victor Dorneanu

COMMANDS: authenticate, a Authenticate against identity providers post, p Post some article help, h Shows a list of commands or help for one command

GLOBAL OPTIONS: --help, -h show help (default: false) --version, -v print the version (default: false)

$ ./gocial a


/ // / ___ / // / _ / _
/
/_
////___/ v4.7.2 High performance, minimalist Go web framework https://echo.labstack.com _____________________________O/ O
⇨ http server started on 127.0.0.1:3000

#+end_src

  • More screenshots ~gocial~ connects to twitter:

[[file:docs/images/gocial-connects-to-twitter.png]]

~gocial~ after successful logins:

[[file:docs/images/gocial-after-successful-logins.png]]

Sharing an article:

[[file:docs/images/gocial-share-article.png]]

  • COMMENT Local Variables :noexport:

Local Variables:

after-save-hook: org-html-export-to-html

End: