// apcore is a server framework for implementing an ActivityPub application. // Copyright (C) 2019 Cory Slep // // 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 . package main import ( "context" "fmt" "html/template" "net/http" "net/url" "strings" "time" "github.com/go-fed/activity/pub" "github.com/go-fed/activity/streams" "github.com/go-fed/activity/streams/vocab" "github.com/go-fed/apcore/app" "github.com/go-fed/apcore/paths" "github.com/go-fed/apcore/util" "github.com/google/uuid" ) const ( notFoundTemplate = "not_found.tmpl" notAllowedTemplate = "not_allowed.tmpl" internalErrorTemplate = "internal_error.tmpl" badRequestTemplate = "bad_request.tmpl" loginTemplate = "login.tmpl" authTemplate = "auth.tmpl" inboxTemplate = "inbox.tmpl" outboxTemplate = "outbox.tmpl" followersTemplate = "followers.tmpl" followingTemplate = "following.tmpl" userTemplate = "user.tmpl" listUsersTemplate = "list_users.tmpl" homeTemplate = "home.tmpl" createNoteTemplate = "create_note.tmpl" listNotesTemplate = "list_notes.tmpl" noteTemplate = "note.tmpl" ) var _ app.Application = &App{} var _ app.S2SApplication = &App{} var _ app.C2SApplication = &App{} var fm template.FuncMap = map[string]interface{}{ "seq": func(n int) []int { v := make([]int, n) for i := 1; i <= n; i++ { v[i-1] = i } return v }, "isString": func(i interface{}) bool { _, ok := i.(string) return ok }, } // App is an example application that minimally implements the // app.Application interface. type App struct { // startTime is set when Start is called startTime time.Time templates *template.Template } // newApplication creates a new App for the framework to use. func newApplication(glob string) (*App, error) { t, err := template.New("").Funcs(fm).ParseGlob(glob) if err != nil { return nil, err } util.InfoLogger.Infof("Templates found:") for _, tp := range t.Templates() { util.InfoLogger.Infof("%s", tp.Name()) } return &App{ templates: t, }, nil } // Start marks when the uptime began for our application. func (a *App) Start() error { a.startTime = time.Now() // Server timezone. return nil } // Stop doesn't do anything for the example application. func (a *App) Stop() error { return nil } // NewConfiguration returns our custom struct we would like to be populated by // administrators running our software. These values don't do anything in our // example application. func (a *App) NewConfiguration() interface{} { return nil } // SetConfiguration is called with the same type that is returned in // NewConfiguration, and allows us to save a copy of the values that an // administrator has configured for our software. // // Note we don't do anything with the configuration values in this example // application. But don't let that stop your imagination from taking off! func (a *App) SetConfiguration(i interface{}) error { return nil } // This example server software supports the Social API, C2S. We don't have to. // But it makes sense to support one of {C2S, S2S}, otherwise what are you // doing here? func (a *App) C2SEnabled() bool { return true } // This example server software supports the Federation API, S2S. We don't have // to. But it makes sense to support one of {C2S, S2S}, otherwise what are you // doing here? func (a *App) S2SEnabled() bool { return true } // NotFoundHandler returns our spiffy 404 page. func (a *App) NotFoundHandler(f app.Framework) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { a.getSessionWriteTemplateHelper(w, r, f, http.StatusNotFound, notFoundTemplate, nil, "NotFoundHandler") }) } // MethodNotAllowedHandler would scold the user for choosing unorthodox methods // that resulted in this error, but in this instance of the universe only sends // a boring reply. func (a *App) MethodNotAllowedHandler(f app.Framework) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { a.getSessionWriteTemplateHelper(w, r, f, http.StatusMethodNotAllowed, notAllowedTemplate, nil, "MethodNotAllowedHandler") }) } // InternalServerErrorHandler puts the underlying operating system into the time // out corner. Haha, just kidding, that was a joke. Laugh. func (a *App) InternalServerErrorHandler(f app.Framework) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s, err := f.Session(r) if err != nil { util.ErrorLogger.Errorf("Error getting session: %v", err) } w.WriteHeader(http.StatusInternalServerError) err = a.templates.ExecuteTemplate(w, internalErrorTemplate, a.getTemplateData(s, nil)) if err != nil { util.ErrorLogger.Errorf("Error serving InternalServerErrorHandler: %v", err) } }) } // BadRequestHandler is "I ran out of witty things to say, so I hope you // understand the pattern by now" level of error handling. Don't let this // limited example and snarky commentary demotivate you. If you wanted cold, // soulless enterprisey software, you came to the wrong place. func (a *App) BadRequestHandler(f app.Framework) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { a.getSessionWriteTemplateHelper(w, r, f, http.StatusBadRequest, badRequestTemplate, nil, "BadRequestHandler") }) } // GetLoginWebHandlerFunc returns a handler that renders the login page for // the user. // // The form should POST to "/login", and if the query parameter "login_error" // is "true" then it should also render the "email or password incorrect" error // message. func (a *App) GetLoginWebHandlerFunc(f app.Framework) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a.getSessionWriteTemplateHelper(w, r, f, http.StatusOK, loginTemplate, nil, "GetLoginWebHandlerFunc") } } // GetAuthWebHandlerFunc returns a handler that renders the authorization page // for the user to approve in the OAuth2 flow. func (a *App) GetAuthWebHandlerFunc(f app.Framework) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { a.getSessionWriteTemplateHelper(w, r, f, http.StatusOK, authTemplate, nil, "GetAuthWebHandlerFunc") } } // GetInboxWebHandlerFunc returns a function rendering the outbox. The framework // passes in a public-only or private view of the outbox, depending on the // authorization of the incoming request. func (a *App) GetInboxWebHandlerFunc(f app.Framework) func(w http.ResponseWriter, r *http.Request, outbox vocab.ActivityStreamsOrderedCollectionPage) { return func(w http.ResponseWriter, r *http.Request, inbox vocab.ActivityStreamsOrderedCollectionPage) { a.getSessionWriteTemplateHelper(w, r, f, http.StatusOK, inboxTemplate, inbox, "GetInboxWebHandlerFunc") } } // GetOutboxWebHandlerFunc returns a function rendering the outbox. The // framework passes in a public-only or private view of the outbox, depending on // the authorization of the incoming request. func (a *App) GetOutboxWebHandlerFunc(f app.Framework) func(w http.ResponseWriter, r *http.Request, outbox vocab.ActivityStreamsOrderedCollectionPage) { return func(w http.ResponseWriter, r *http.Request, outbox vocab.ActivityStreamsOrderedCollectionPage) { a.getSessionWriteTemplateHelper(w, r, f, http.StatusOK, outboxTemplate, outbox, "GetOutboxWebHandlerFunc") } } func (a *App) GetFollowersWebHandlerFunc(f app.Framework) (app.CollectionPageHandlerFunc, app.AuthorizeFunc) { return func(w http.ResponseWriter, r *http.Request, followers vocab.ActivityStreamsCollectionPage) { a.getSessionWriteTemplateHelper(w, r, f, http.StatusOK, followersTemplate, followers, "GetFollowersWebHandlerFunc") }, func(c util.Context, w http.ResponseWriter, r *http.Request, db app.Database) (permit bool, err error) { return true, nil } } func (a *App) GetFollowingWebHandlerFunc(f app.Framework) (app.CollectionPageHandlerFunc, app.AuthorizeFunc) { return func(w http.ResponseWriter, r *http.Request, following vocab.ActivityStreamsCollectionPage) { a.getSessionWriteTemplateHelper(w, r, f, http.StatusOK, followingTemplate, following, "GetFollowingWebHandlerFunc") }, func(c util.Context, w http.ResponseWriter, r *http.Request, db app.Database) (permit bool, err error) { return true, nil } } // GetLikedWebHandlerFunc would have us fetch the user's liked collection and // then display it in a webpage. Instead, we return null so there's no way to // view the content as a webpage, but instead it is only obtainable as public // ActivityStreams data. func (a *App) GetLikedWebHandlerFunc(f app.Framework) (app.CollectionPageHandlerFunc, app.AuthorizeFunc) { return nil, nil } func (a *App) GetUserWebHandlerFunc(f app.Framework) (app.VocabHandlerFunc, app.AuthorizeFunc) { return func(w http.ResponseWriter, r *http.Request, user vocab.Type) { a.getSessionWriteTemplateHelper(w, r, f, http.StatusOK, userTemplate, user, "GetUserWebHandlerFunc") }, func(c util.Context, w http.ResponseWriter, r *http.Request, db app.Database) (permit bool, err error) { return true, nil } } // BuildRoutes takes a Router and builds the endpoint http.Handler core. // // A database handle and a supplementary Framework object are provided for // convenience and use in the server's handlers. func (a *App) BuildRoutes(r app.Router, db app.Database, f app.Framework) error { // When building routes, the framework already provides actors at the // endpoint: // // /users/{user} // // And further routes for the inbox, outbox, followers, following, and // liked collections. If you want to use web handlers at these // endpoints, other App interface functions allow you to do so. // // The framework also provides out-of-the-box OAuth2 supporting // endpoints: // // /login (GET & POST) // /logout (GET) // /authorize (GET & POST) // /token (GET) // // The framework also handles registering webfinger and host-meta // routes: // // /.well-known/host-meta // /.well-known/webfinger // // And supports using Webfinger to find actors on this server. // // Here we save copeies of our error handlers. internalErrorHandler := a.InternalServerErrorHandler(f) badRequestHandler := a.BadRequestHandler(f) // WebOnlyHandleFunc is a convenience function for endpoints with only // web content available; no ActivityStreams content exists at this // endpoint. // // It is sugar for Path(...).HandlerFunc(...) r.WebOnlyHandleFunc("/", func(w http.ResponseWriter, r *http.Request) { s, err := f.Session(r) if err != nil { util.ErrorLogger.Errorf("Error getting session: %v", err) a.InternalServerErrorHandler(f).ServeHTTP(w, r) return } notes, err := getLatestPublicNotes(r.Context(), db) if err != nil { util.ErrorLogger.Errorf("Error getting latest notes: %v", err) a.InternalServerErrorHandler(f).ServeHTTP(w, r) return } err = a.templates.ExecuteTemplate(w, homeTemplate, a.getTemplateData(s, notes)) if err != nil { util.ErrorLogger.Errorf("Error serving home template: %v", err) } }) r.WebOnlyHandleFunc("/users", func(w http.ResponseWriter, r *http.Request) { s, err := f.Session(r) if err != nil { util.ErrorLogger.Errorf("Error getting session: %v", err) a.InternalServerErrorHandler(f).ServeHTTP(w, r) return } users, err := getUsers(r.Context(), db) if err != nil { util.ErrorLogger.Errorf("Error getting latest notes: %v", err) a.InternalServerErrorHandler(f).ServeHTTP(w, r) return } err = a.templates.ExecuteTemplate(w, listUsersTemplate, a.getTemplateData(s, users)) if err != nil { util.ErrorLogger.Errorf("Error serving list users template: %v", err) } }) // ActivityPubHandleFunc is a convenience function for endpoints with // only ActivityPub content; no web content exists at this endpoint. r.ActivityPubOnlyHandleFunc("/activities/{activity}", func(c util.Context, w http.ResponseWriter, r *http.Request, db app.Database) (permit bool, err error) { return true, nil }) // You can use familiar mux methods to route requests appropriately. // // Add a handler listing the latest notes we are allowed to see: // 1) Local public. // 2) Ones we have published. // 3) Ones with us in the 'to' or 'cc'. r.NewRoute().Path("/notes").Methods("GET").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s, err := f.Session(r) if err != nil { util.ErrorLogger.Errorf("Error getting session: %v", err) a.InternalServerErrorHandler(f).ServeHTTP(w, r) return } // View list of existing public (and maybe private) notes with // pagination. userID, authd, err := f.Validate(w, r) if err != nil { util.ErrorLogger.Errorf("error validating token/creds in GET /notes: %s", err) // continue processing request as unauthenticated. } var notes []vocab.Type if err != nil || !authd { notes, err = getLatestPublicNotes(r.Context(), db) } else { userIRI := f.UserIRI(userID) notes, err = getLatestNotesAndMyPrivateNotes(r.Context(), db, userIRI.String()) } if err != nil { util.ErrorLogger.Errorf("error getting notes: %s", err) internalErrorHandler.ServeHTTP(w, r) return } err = a.templates.ExecuteTemplate(w, listNotesTemplate, a.getTemplateData(s, notes)) if err != nil { util.ErrorLogger.Errorf("Error serving list notes template: %v", err) } }) // Next, a webpage to handle creating, updating, and deleting notes. // This is NOT via C2S, but is done natively in our application. r.NewRoute().Path("/notes/create").Methods("GET").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s, err := f.Session(r) if err != nil { util.ErrorLogger.Errorf("Error getting session: %v", err) a.InternalServerErrorHandler(f).ServeHTTP(w, r) return } // Ensure the user is logged in. _, authd, err := f.Validate(w, r) if err != nil { util.ErrorLogger.Errorf("error validating oauth2 token in GET /notes/create: %s", err) internalErrorHandler.ServeHTTP(w, r) return } if !authd { http.Redirect(w, r, "/login", http.StatusFound) return } // Render the webpage. err = a.templates.ExecuteTemplate(w, createNoteTemplate, a.getTemplateData(s, nil)) if err != nil { util.ErrorLogger.Errorf("Error serving create note template: %v", err) } }) r.NewRoute().Path("/notes/create").Methods("POST").HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Ensure the user is logged in. userID, authd, err := f.Validate(w, r) if err != nil { util.ErrorLogger.Errorf("error validating oauth2 token in POST /notes/create: %s", err) internalErrorHandler.ServeHTTP(w, r) return } if !authd { http.Redirect(w, r, "/login", http.StatusFound) return } if r.Form == nil { err = r.ParseForm() if err != nil { badRequestHandler.ServeHTTP(w, r) return } } // Parse the form toV, ok := r.Form["note_to"] if !ok || len(toV) != 1 { util.ErrorLogger.Errorf("error validating to from form") badRequestHandler.ServeHTTP(w, r) return } to := toV[0] summaryV, ok := r.Form["note_summary"] if !ok || len(summaryV) != 1 { util.ErrorLogger.Errorf("error validating summary from form") badRequestHandler.ServeHTTP(w, r) return } summary := summaryV[0] contentV, ok := r.Form["note_content"] if !ok || len(contentV) != 1 { util.ErrorLogger.Errorf("error validating content from form") badRequestHandler.ServeHTTP(w, r) return } content := contentV[0] _, public := r.Form["note_public"] // Build a new Note note := streams.NewActivityStreamsNote() toProp := streams.NewActivityStreamsToProperty() tos := strings.Split(to, ",") for _, t := range tos { toIRI, err := url.Parse(t) if err != nil { util.ErrorLogger.Errorf("error validating an address in to") badRequestHandler.ServeHTTP(w, r) return } toProp.AppendIRI(toIRI) } if public { publicIRI, err := url.Parse(pub.PublicActivityPubIRI) if err != nil { util.ErrorLogger.Errorf("error validating public ActivityStreams address") badRequestHandler.ServeHTTP(w, r) return } toProp.AppendIRI(publicIRI) } note.SetActivityStreamsTo(toProp) summaryProp := streams.NewActivityStreamsSummaryProperty() summaryProp.AppendXMLSchemaString(summary) note.SetActivityStreamsSummary(summaryProp) contentProp := streams.NewActivityStreamsContentProperty() contentProp.AppendXMLSchemaString(content) note.SetActivityStreamsContent(contentProp) ctx := util.Context{r.Context()} // Send the note -- a Create will automatically be created if err := f.Send(ctx, paths.UUID(userID), note); err != nil { util.ErrorLogger.Errorf("error sending when creating note: %s", err) internalErrorHandler.ServeHTTP(w, r) return } iri := note.GetJSONLDId().GetIRI() http.Redirect(w, r, iri.String(), http.StatusFound) }) // First, we need an authentication function to make sure whoever views // the ActivityStreams data has proper credentials to view the web or // ActivityStreams data. authFn := func(c util.Context, w http.ResponseWriter, r *http.Request, db app.Database) (permit bool, err error) { // Determine who, if any, is logged-in. userID, authd, err := f.Validate(w, r) if err != nil { util.ErrorLogger.Errorf("error validating token/creds in GET /notes: %s", err) // continue processing request as unauthenticated. } ctx := f.Context(r) noteID, err := ctx.CompleteRequestURL() if err != nil { util.ErrorLogger.Errorf("error sending when creating note: %s", err) internalErrorHandler.ServeHTTP(w, r) return } if err == nil && authd { userIRI := f.UserIRI(userID) // Authenticated request permit, err = getNoteIsReadable(ctx, db, noteID, userIRI) } else { // Unauthenticated request permit, err = getNoteIsPublic(ctx, db, noteID) } return } // Next, we use the auth function to protect the note. r.ActivityPubAndWebHandleFunc("/notes/{note}", authFn, func(w http.ResponseWriter, r *http.Request) { // View note in web page. s, err := f.Session(r) if err != nil { util.ErrorLogger.Errorf("Error getting session: %v", err) a.InternalServerErrorHandler(f).ServeHTTP(w, r) return } ctx := f.Context(r) noteID, err := ctx.CompleteRequestURL() if err != nil { util.ErrorLogger.Errorf("error sending when creating note: %s", err) internalErrorHandler.ServeHTTP(w, r) return } vt, err := f.GetByIRI(ctx, noteID) if err != nil { util.ErrorLogger.Errorf("error fetching note: %s", err) internalErrorHandler.ServeHTTP(w, r) return } err = a.templates.ExecuteTemplate(w, noteTemplate, a.getTemplateData(s, vt)) if err != nil { util.ErrorLogger.Errorf("Error serving note template: %v", err) } }) return nil } func (a *App) NewIDPath(c context.Context, t vocab.Type) (path string, err error) { switch t.GetTypeName() { case "Note": path = fmt.Sprintf("/notes/%s", uuid.New().String()) case "Create": path = fmt.Sprintf("/activities/%s", uuid.New().String()) default: err = fmt.Errorf("NewID unhandled type name: %s", t.GetTypeName()) } return } // ApplyFederatingCallbacks lets us provide hooks for our application based on // incoming ActivityStreams data from peer servers. func (a *App) ApplyFederatingCallbacks(fwc *pub.FederatingWrappedCallbacks) (others []interface{}) { // Here, we add additional behavior to our application if we receive a // federated Create activity, besides the spec-suggested side effects. // // The additional behavior of our application is to print out the // Create activity to Stdout. fwc.Create = func(c context.Context, create vocab.ActivityStreamsCreate) error { fmt.Println(streams.Serialize(create)) return nil } // Here we add new behavior to our application. // // The new behavior is to print out Listen activities to Stdout. others = []interface{}{ func(c context.Context, listen vocab.ActivityStreamsListen) error { fmt.Println(streams.Serialize(listen)) return nil }, } return } // ApplySocialCallbacks lets us provide hooks for our application based on // incoming ActivityStreams data from a user's ActivityPub client. func (a *App) ApplySocialCallbacks(swc *pub.SocialWrappedCallbacks) (others []interface{}) { // Here we add no new C2S Behavior. Doing nothing in this function will // let the framework handle the suggested C2S side effects. return } // ScopePermitsPostOutbox ensures the OAuth2 token scope is "loggedin", which // is the only permission. Other applications can have more granular // authorization systems. func (a *App) ScopePermitsPostOutbox(scope string) (permitted bool, err error) { return scope == "postOutbox" || scope == "all", nil } // ScopePermitsPrivateGetInbox ensures the OAuth2 token scope is "loggedin", // which is the only permission. Other applications can have more granular // authorization systems. func (a *App) ScopePermitsPrivateGetInbox(scope string) (permitted bool, err error) { return scope == "getInbox" || scope == "all", nil } // ScopePermitsPrivateGetOutbox ensures the OAuth2 token scope is "loggedin", // which is the only permission. Other applications can have more granular // authorization systems. func (a *App) ScopePermitsPrivateGetOutbox(scope string) (permitted bool, err error) { return scope == "getOutbox" || scope == "all", nil } // Software describes the current running software, based on the code. This // allows everyone, from users to developers, to make reasonable judgments about // the state of the Federative ecosystem as a whole. // // Warning: Nothing inherently prevents your application from lying and // attempting to masquerade as another set of software. Don't be that jerk. func (a *App) Software() app.Software { return app.Software{ Name: "kama", MajorVersion: 0, MinorVersion: 1, PatchVersion: 0, } } func (a *App) DefaultUserPreferences() interface{} { return nil } func (a *App) DefaultUserPrivileges() interface{} { return nil } func (a *App) DefaultAdminPrivileges() interface{} { return nil } // This is a helper function to generate common data needed in the web // templates. func (a *App) getTemplateData(s app.Session, other interface{}) map[string]interface{} { if vt, ok := other.(vocab.Type); ok { svt, err := streams.Serialize(vt) if err == nil { other = svt } else { util.ErrorLogger.Errorf("error serializing ActivityStreams for rendering: %s", err) } } if vts, ok := other.([]vocab.Type); ok { var svts []map[string]interface{} for _, vt := range vts { svt, err := streams.Serialize(vt) if err == nil { svts = append(svts, svt) } else { util.ErrorLogger.Errorf("error serializing ActivityStreams for rendering: %s", err) } } other = svts } m := map[string]interface{}{ "Other": other, "Nav": []struct { Href string Name string }{ { Href: "/", Name: "home", }, { Href: "/users", Name: "users", }, { Href: "/notes", Name: "notes", }, }, } if s != nil { user, err := s.UserID() if err == nil && len(user) > 0 { m["User"] = user } } return m } func (a *App) getSessionWriteTemplateHelper(w http.ResponseWriter, r *http.Request, f app.Framework, code int, tmpl string, data interface{}, debug string) { s, err := f.Session(r) if err != nil { util.ErrorLogger.Errorf("Error getting session: %v", err) a.InternalServerErrorHandler(f).ServeHTTP(w, r) return } w.WriteHeader(code) err = a.templates.ExecuteTemplate(w, tmpl, a.getTemplateData(s, data)) if err != nil { util.ErrorLogger.Errorf("Error serving %s: %v", debug, err) } }