├── .gitignore ├── LICENSE ├── TODO ├── actor.go ├── go.mod ├── go.sum ├── http.go ├── nodeinfo.go ├── readme.md ├── remoteActor.go ├── setup.go ├── snips.md └── util.go /.gitignore: -------------------------------------------------------------------------------- 1 | *.snip 2 | storage -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Write.as and authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | [✔] Follow users 2 | [✔] Announcements 3 | [✔] Federate the post to our followers (hardcoded for now) 4 | [✔] Handle more than one local actors 5 | [✔] Handle the /actor endpoint 6 | [✔] Create configuration file 7 | [✔] Implement database backend 8 | [✔] Create a file with the actors we have, their following 9 | and their followers. 10 | [✔] `MakeActor` should create a file with that actor. 11 | [✔] Implement `LoadActor` 12 | [✔] `actor.Follow` should write the new following to file 13 | [✔] Handle being followed 14 | [✔] When followed, the handler should write the new follower to file 15 | [✔] Make sure we send our boosts to all our followers 16 | [x] Write incoming activities to disk (do we have to?) 17 | [✔] Write all the announcements (boosts) to the database to 18 | their correct actors 19 | [✔] Check if we are already following users 20 | [✔] On GetOutbox read the database and present a list of the 21 | last posts. 22 | [✔] Make OS-independent (mosty directory separators) 23 | [✔] Create outbox.json programmatically 24 | [✔] Make storage configurable (search for "storage" in project) 25 | [✔] Check if we're boosting only stuff from actors we follow, not whatever comes 26 | through in our inbox 27 | [✔] Boost not only articles but other things too 28 | [✔] Sanitize input, never allow slashes or dots 29 | [✔] Add summary to actors.json 30 | [✔] Check local actor names for characters illegal for filenames and ban them 31 | (Done in pherephone, not activityserve) 32 | [✔] Create debug flag 33 | [✔] Write to following only upon accept 34 | (waiting to actually get an accept so that I can test this) 35 | [✔] Implement webfinger 36 | [✔] Make sure masto finds signature 37 | [✔] Implement Unfollow 38 | [✔] Implement accept (accept when other follow us) 39 | (done but can't test it pending http signatures) 40 | Works in pleroma/pixelfed not working on masto 41 | (nothing works on masto) 42 | [ ] Implement nodeinfo and statistics 43 | [✔] Accept even if already follows us 44 | [✔] Handle paging 45 | [✔] Test paging 46 | [✔] Handle http signatures 47 | [ ] Verify http signatures 48 | [✔] Refactor, comment and clean up 49 | [✔] Split to pherephone and activityServe 50 | [ ] Decide what's to be done with actors removed from `actors.json`. 51 | [ ] Remove them? 52 | [ ] Leave them read-only? 53 | [✔] Leave them as is? 54 | [✔] Handle followers and following uri's 55 | [ ] Do I care about the inbox? 56 | [✔] Expose configuration to apps 57 | [✔] Do not boost replies (configurable) -------------------------------------------------------------------------------- /actor.go: -------------------------------------------------------------------------------- 1 | package activityserve 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "strconv" 13 | "strings" 14 | "time" 15 | 16 | "github.com/gologme/log" 17 | 18 | "crypto" 19 | "crypto/rand" 20 | "crypto/rsa" 21 | "crypto/x509" 22 | "encoding/pem" 23 | 24 | "github.com/dchest/uniuri" 25 | "github.com/go-fed/httpsig" 26 | ) 27 | 28 | // Actor represents a local actor we can act on 29 | // behalf of. 30 | type Actor struct { 31 | Name, summary, actorType, iri string 32 | followersIRI string 33 | nuIri *url.URL 34 | followers, following, rejected map[string]interface{} 35 | requested map[string]interface{} 36 | posts map[int]map[string]interface{} 37 | publicKey crypto.PublicKey 38 | privateKey crypto.PrivateKey 39 | publicKeyPem string 40 | privateKeyPem string 41 | publicKeyID string 42 | OnFollow func(map[string]interface{}) 43 | OnReceiveContent func(map[string]interface{}) 44 | } 45 | 46 | // ActorToSave is a stripped down actor representation 47 | // with exported properties in order for json to be 48 | // able to marshal it. 49 | // see https://stackoverflow.com/questions/26327391/json-marshalstruct-returns 50 | type ActorToSave struct { 51 | Name, Summary, ActorType, IRI, PublicKey, PrivateKey string 52 | Followers, Following, Rejected, Requested map[string]interface{} 53 | } 54 | 55 | // MakeActor creates and returns a new local actor we can act 56 | // on behalf of. It also creates its files on disk 57 | func MakeActor(name, summary, actorType string) (Actor, error) { 58 | followers := make(map[string]interface{}) 59 | following := make(map[string]interface{}) 60 | rejected := make(map[string]interface{}) 61 | requested := make(map[string]interface{}) 62 | followersIRI := baseURL + name + "/followers" 63 | publicKeyID := baseURL + name + "#main-key" 64 | iri := baseURL + name 65 | nuIri, err := url.Parse(iri) 66 | if err != nil { 67 | log.Info("Something went wrong when parsing the local actor uri into net/url") 68 | return Actor{}, err 69 | } 70 | actor := Actor{ 71 | Name: name, 72 | summary: summary, 73 | actorType: actorType, 74 | iri: iri, 75 | nuIri: nuIri, 76 | followers: followers, 77 | following: following, 78 | rejected: rejected, 79 | requested: requested, 80 | followersIRI: followersIRI, 81 | publicKeyID: publicKeyID, 82 | } 83 | 84 | // set auto accept by default (this could be a configuration value) 85 | actor.OnFollow = func(activity map[string]interface{}) { actor.Accept(activity) } 86 | actor.OnReceiveContent = func(activity map[string]interface{}) {} 87 | 88 | // create actor's keypair 89 | rng := rand.Reader 90 | privateKey, err := rsa.GenerateKey(rng, 2048) 91 | publicKey := privateKey.PublicKey 92 | 93 | actor.publicKey = publicKey 94 | actor.privateKey = privateKey 95 | 96 | // marshal the crypto to pem 97 | privateKeyDer := x509.MarshalPKCS1PrivateKey(privateKey) 98 | privateKeyBlock := pem.Block{ 99 | Type: "RSA PRIVATE KEY", 100 | Headers: nil, 101 | Bytes: privateKeyDer, 102 | } 103 | actor.privateKeyPem = string(pem.EncodeToMemory(&privateKeyBlock)) 104 | 105 | publicKeyDer, err := x509.MarshalPKIXPublicKey(&publicKey) 106 | if err != nil { 107 | log.Info("Can't marshal public key") 108 | return Actor{}, err 109 | } 110 | 111 | publicKeyBlock := pem.Block{ 112 | Type: "PUBLIC KEY", 113 | Headers: nil, 114 | Bytes: publicKeyDer, 115 | } 116 | actor.publicKeyPem = string(pem.EncodeToMemory(&publicKeyBlock)) 117 | 118 | err = actor.save() 119 | if err != nil { 120 | return actor, err 121 | } 122 | 123 | return actor, nil 124 | } 125 | 126 | // GetOutboxIRI returns the outbox iri in net/url format 127 | func (a *Actor) GetOutboxIRI() *url.URL { 128 | iri := a.iri + "/outbox" 129 | nuiri, _ := url.Parse(iri) 130 | return nuiri 131 | } 132 | 133 | // LoadActor searches the filesystem and creates an Actor 134 | // from the data in .json 135 | // This does not preserve events so use with caution 136 | func LoadActor(name string) (Actor, error) { 137 | // make sure our users can't read our hard drive 138 | if strings.ContainsAny(name, "./ ") { 139 | log.Info("Illegal characters in actor name") 140 | return Actor{}, errors.New("Illegal characters in actor name") 141 | } 142 | jsonFile := storage + slash + "actors" + slash + name + slash + name + ".json" 143 | fileHandle, err := os.Open(jsonFile) 144 | if os.IsNotExist(err) { 145 | log.Info(name) 146 | log.Info("We don't have this kind of actor stored") 147 | return Actor{}, err 148 | } 149 | byteValue, err := ioutil.ReadAll(fileHandle) 150 | if err != nil { 151 | log.Info("Error reading actor file") 152 | return Actor{}, err 153 | } 154 | jsonData := make(map[string]interface{}) 155 | json.Unmarshal(byteValue, &jsonData) 156 | 157 | nuIri, err := url.Parse(jsonData["IRI"].(string)) 158 | if err != nil { 159 | log.Info("Something went wrong when parsing the local actor uri into net/url") 160 | return Actor{}, err 161 | } 162 | 163 | publicKeyDecoded, rest := pem.Decode([]byte(jsonData["PublicKey"].(string))) 164 | if publicKeyDecoded == nil { 165 | log.Info(rest) 166 | panic("failed to parse PEM block containing the public key") 167 | } 168 | publicKey, err := x509.ParsePKIXPublicKey(publicKeyDecoded.Bytes) 169 | if err != nil { 170 | log.Info("Can't parse public keys") 171 | log.Info(err) 172 | return Actor{}, err 173 | } 174 | privateKeyDecoded, rest := pem.Decode([]byte(jsonData["PrivateKey"].(string))) 175 | if privateKeyDecoded == nil { 176 | log.Info(rest) 177 | panic("failed to parse PEM block containing the private key") 178 | } 179 | privateKey, err := x509.ParsePKCS1PrivateKey(privateKeyDecoded.Bytes) 180 | if err != nil { 181 | log.Info("Can't parse private keys") 182 | log.Info(err) 183 | return Actor{}, err 184 | } 185 | 186 | actor := Actor{ 187 | Name: name, 188 | summary: jsonData["Summary"].(string), 189 | actorType: jsonData["ActorType"].(string), 190 | iri: jsonData["IRI"].(string), 191 | nuIri: nuIri, 192 | followers: jsonData["Followers"].(map[string]interface{}), 193 | following: jsonData["Following"].(map[string]interface{}), 194 | rejected: jsonData["Rejected"].(map[string]interface{}), 195 | requested: jsonData["Requested"].(map[string]interface{}), 196 | publicKey: publicKey, 197 | privateKey: privateKey, 198 | publicKeyPem: jsonData["PublicKey"].(string), 199 | privateKeyPem: jsonData["PrivateKey"].(string), 200 | followersIRI: baseURL + name + "/followers", 201 | publicKeyID: baseURL + name + "#main-key", 202 | } 203 | 204 | actor.OnFollow = func(activity map[string]interface{}) { actor.Accept(activity) } 205 | actor.OnReceiveContent = func(activity map[string]interface{}) {} 206 | 207 | return actor, nil 208 | } 209 | 210 | // GetActor attempts to LoadActor and if it doesn't exist 211 | // creates one 212 | func GetActor(name, summary, actorType string) (Actor, error) { 213 | actor, err := LoadActor(name) 214 | 215 | if err != nil { 216 | log.Info("Actor doesn't exist, creating...") 217 | actor, err = MakeActor(name, summary, actorType) 218 | if err != nil { 219 | log.Info("Can't create actor!") 220 | return Actor{}, err 221 | } 222 | } 223 | 224 | // if the info provided for the actor is different 225 | // from what the actor has, edit the actor 226 | save := false 227 | if summary != actor.summary { 228 | actor.summary = summary 229 | save = true 230 | } 231 | if actorType != actor.actorType { 232 | actor.actorType = actorType 233 | save = true 234 | } 235 | // if anything changed write it to disk 236 | if save { 237 | actor.save() 238 | } 239 | 240 | return actor, nil 241 | } 242 | 243 | // func LoadActorFromIRI(iri string) a Actor{ 244 | // TODO, this should parse the iri and load the right actor 245 | // } 246 | 247 | // save the actor to file 248 | func (a *Actor) save() error { 249 | 250 | // check if we already have a directory to save actors 251 | // and if not, create it 252 | dir := storage + slash + "actors" + slash + a.Name + slash + "items" 253 | if _, err := os.Stat(dir); os.IsNotExist(err) { 254 | os.MkdirAll(dir, 0755) 255 | } 256 | 257 | actorToSave := ActorToSave{ 258 | Name: a.Name, 259 | Summary: a.summary, 260 | ActorType: a.actorType, 261 | IRI: a.iri, 262 | Followers: a.followers, 263 | Following: a.following, 264 | Rejected: a.rejected, 265 | Requested: a.requested, 266 | PublicKey: a.publicKeyPem, 267 | PrivateKey: a.privateKeyPem, 268 | } 269 | 270 | actorJSON, err := json.MarshalIndent(actorToSave, "", "\t") 271 | if err != nil { 272 | log.Info("error Marshalling actor json") 273 | return err 274 | } 275 | // log.Info(actorToSave) 276 | // log.Info(string(actorJSON)) 277 | err = ioutil.WriteFile(storage+slash+"actors"+slash+a.Name+slash+a.Name+".json", actorJSON, 0644) 278 | if err != nil { 279 | log.Printf("WriteFileJson ERROR: %+v", err) 280 | return err 281 | } 282 | 283 | return nil 284 | } 285 | 286 | func (a *Actor) whoAmI() string { 287 | 288 | self := make(map[string]interface{}) 289 | self["@context"] = context() 290 | self["type"] = a.actorType 291 | self["id"] = baseURL + a.Name 292 | self["name"] = a.Name 293 | self["preferredUsername"] = a.Name 294 | self["summary"] = a.summary 295 | self["inbox"] = baseURL + a.Name + "/inbox" 296 | self["outbox"] = baseURL + a.Name + "/outbox" 297 | self["followers"] = baseURL + a.Name + "/peers/followers" 298 | self["following"] = baseURL + a.Name + "/peers/following" 299 | self["publicKey"] = map[string]string{ 300 | "id": baseURL + a.Name + "#main-key", 301 | "owner": baseURL + a.Name, 302 | "publicKeyPem": a.publicKeyPem, 303 | } 304 | selfString, _ := json.Marshal(self) 305 | return string(selfString) 306 | } 307 | 308 | func (a *Actor) newItemID() (hash string, url string) { 309 | hash = uniuri.New() 310 | return hash, baseURL + a.Name + "/item/" + hash 311 | } 312 | 313 | func (a *Actor) newID() (hash string, url string) { 314 | hash = uniuri.New() 315 | return hash, baseURL + a.Name + "/" + hash 316 | } 317 | 318 | // TODO Reply(content string, inReplyTo string) 319 | 320 | // ReplyNote sends a note to a specific actor in reply to 321 | // a post 322 | //TODO 323 | 324 | // DM sends a direct message to a user 325 | // TODO 326 | 327 | // CreateNote posts an activityPub note to our followers 328 | func (a *Actor) CreateNote(content, inReplyTo string) { 329 | // for now I will just write this to the outbox 330 | hash, id := a.newItemID() 331 | create := make(map[string]interface{}) 332 | note := make(map[string]interface{}) 333 | create["@context"] = context() 334 | create["actor"] = baseURL + a.Name 335 | create["cc"] = a.followersIRI 336 | create["id"] = id 337 | create["object"] = note 338 | note["attributedTo"] = baseURL + a.Name 339 | note["cc"] = a.followersIRI 340 | note["content"] = content 341 | if inReplyTo != "" { 342 | note["inReplyTo"] = inReplyTo 343 | } 344 | note["id"] = id 345 | note["published"] = time.Now().Format(time.RFC3339) 346 | note["url"] = create["id"] 347 | note["type"] = "Note" 348 | note["to"] = "https://www.w3.org/ns/activitystreams#Public" 349 | create["published"] = note["published"] 350 | create["type"] = "Create" 351 | go a.sendToFollowers(create) 352 | err := a.saveItem(hash, create) 353 | if err != nil { 354 | log.Info("Could not save note to disk") 355 | } 356 | err = a.appendToOutbox(id) 357 | if err != nil { 358 | log.Info("Could not append Note to outbox.txt") 359 | } 360 | } 361 | 362 | // saveItem saves an activity to disk under the actor and with the id as 363 | // filename 364 | func (a *Actor) saveItem(hash string, content map[string]interface{}) error { 365 | JSON, _ := json.MarshalIndent(content, "", "\t") 366 | 367 | dir := storage + slash + "actors" + slash + a.Name + slash + "items" 368 | err := ioutil.WriteFile(dir+slash+hash+".json", JSON, 0644) 369 | if err != nil { 370 | log.Printf("WriteFileJson ERROR: %+v", err) 371 | return err 372 | } 373 | return nil 374 | } 375 | 376 | func (a *Actor) loadItem(hash string) (item map[string]interface{}, err error) { 377 | dir := storage + slash + "actors" + slash + a.Name + slash + "items" 378 | jsonFile := dir + slash + hash + ".json" 379 | fileHandle, err := os.Open(jsonFile) 380 | if os.IsNotExist(err) { 381 | log.Info("We don't have this item stored") 382 | return 383 | } 384 | byteValue, err := ioutil.ReadAll(fileHandle) 385 | if err != nil { 386 | log.Info("Error reading item file") 387 | return 388 | } 389 | json.Unmarshal(byteValue, &item) 390 | 391 | return 392 | } 393 | 394 | // send is here for backward compatibility and maybe extra pre-processing 395 | // not always required 396 | func (a *Actor) send(content map[string]interface{}, to *url.URL) (err error) { 397 | return a.signedHTTPPost(content, to.String()) 398 | } 399 | 400 | // getPeers gets followers or following depending on `who` 401 | func (a *Actor) getPeers(page int, who string) (response []byte, err error) { 402 | // if there's no page parameter mastodon displays an 403 | // OrderedCollection with info of where to find orderedCollectionPages 404 | // with the actual information. We are mirroring that behavior 405 | 406 | var collection map[string]interface{} 407 | if who == "followers" { 408 | collection = a.followers 409 | } else if who == "following" { 410 | collection = a.following 411 | } else { 412 | return nil, errors.New("cannot find collection" + who) 413 | } 414 | themap := make(map[string]interface{}) 415 | themap["@context"] = context() 416 | if page == 0 { 417 | themap["first"] = baseURL + a.Name + "/peers/" + who + "?page=1" 418 | themap["id"] = baseURL + a.Name + "/peers/" + who 419 | themap["totalItems"] = strconv.Itoa(len(collection)) 420 | themap["type"] = "OrderedCollection" 421 | } else if page == 1 { // implement pagination 422 | themap["id"] = baseURL + a.Name + who + "?page=" + strconv.Itoa(page) 423 | items := make([]string, 0, len(collection)) 424 | for k := range collection { 425 | items = append(items, k) 426 | } 427 | themap["orderedItems"] = items 428 | themap["partOf"] = baseURL + a.Name + "/peers/" + who 429 | themap["totalItems"] = len(collection) 430 | themap["type"] = "OrderedCollectionPage" 431 | } 432 | response, _ = json.Marshal(themap) 433 | return 434 | } 435 | 436 | // GetFollowers returns a list of people that follow us 437 | func (a *Actor) GetFollowers(page int) (response []byte, err error) { 438 | return a.getPeers(page, "followers") 439 | } 440 | 441 | // GetFollowing returns a list of people that we follow 442 | func (a *Actor) GetFollowing(page int) (response []byte, err error) { 443 | return a.getPeers(page, "following") 444 | } 445 | 446 | // signedHTTPPost performs an HTTP post on behalf of Actor with the 447 | // request-target, date, host and digest headers signed 448 | // with the actor's private key. 449 | func (a *Actor) signedHTTPPost(content map[string]interface{}, to string) (err error) { 450 | b, err := json.Marshal(content) 451 | if err != nil { 452 | log.Info("Can't marshal JSON") 453 | log.Info(err) 454 | return 455 | } 456 | postSigner, _, _ := httpsig.NewSigner([]httpsig.Algorithm{httpsig.RSA_SHA256}, "SHA-256", []string{"(request-target)", "date", "host", "digest"}, httpsig.Signature, 0) 457 | 458 | byteCopy := make([]byte, len(b)) 459 | copy(byteCopy, b) 460 | buf := bytes.NewBuffer(byteCopy) 461 | req, err := http.NewRequest("POST", to, buf) 462 | if err != nil { 463 | log.Info(err) 464 | return 465 | } 466 | 467 | // I prefer to deal with strings and just parse to net/url if and when 468 | // needed, even if here we do one extra round trip 469 | iri, err := url.Parse(to) 470 | if err != nil { 471 | log.Error("cannot parse url for POST, check your syntax") 472 | return err 473 | } 474 | req.Header.Add("Accept-Charset", "utf-8") 475 | req.Header.Add("Date", time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05")+" GMT") 476 | req.Header.Add("User-Agent", userAgent+" "+version) 477 | req.Header.Add("Host", iri.Host) 478 | req.Header.Add("Accept", "application/activity+json; charset=utf-8") 479 | req.Header.Add("Content-Type", "application/activity+json; charset=utf-8") 480 | err = postSigner.SignRequest(a.privateKey, a.publicKeyID, req, byteCopy) 481 | if err != nil { 482 | log.Info(err) 483 | return 484 | } 485 | resp, err := client.Do(req) 486 | if err != nil { 487 | log.Info(err) 488 | return 489 | } 490 | defer resp.Body.Close() 491 | if !isSuccess(resp.StatusCode) { 492 | responseData, _ := ioutil.ReadAll(resp.Body) 493 | err = fmt.Errorf("POST request to %s failed (%d): %s\nResponse: %s \nRequest: %s \nHeaders: %s", to, resp.StatusCode, resp.Status, FormatJSON(responseData), FormatJSON(byteCopy), FormatHeaders(req.Header)) 494 | log.Info(err) 495 | return 496 | } 497 | responseData, _ := ioutil.ReadAll(resp.Body) 498 | log.Errorf("POST request to %s succeeded (%d): %s \nResponse: %s \nRequest: %s \nHeaders: %s", to, resp.StatusCode, resp.Status, FormatJSON(responseData), FormatJSON(byteCopy), FormatHeaders(req.Header)) 499 | return 500 | } 501 | 502 | func (a *Actor) signedHTTPGet(address string) (string, error) { 503 | req, err := http.NewRequest("GET", address, nil) 504 | if err != nil { 505 | log.Error("cannot create new http.request") 506 | return "", err 507 | } 508 | 509 | iri, err := url.Parse(address) 510 | if err != nil { 511 | log.Error("cannot parse url for GET, check your syntax") 512 | return "", err 513 | } 514 | 515 | req.Header.Add("Accept-Charset", "utf-8") 516 | req.Header.Add("Date", time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05")+" GMT") 517 | req.Header.Add("User-Agent", fmt.Sprintf("%s %s %s", userAgent, libName, version)) 518 | req.Header.Add("host", iri.Host) 519 | req.Header.Add("digest", "") 520 | req.Header.Add("Accept", "application/activity+json; profile=\"https://www.w3.org/ns/activitystreams\"") 521 | 522 | // set up the http signer 523 | signer, _, _ := httpsig.NewSigner([]httpsig.Algorithm{httpsig.RSA_SHA256}, "SHA-256", []string{"(request-target)", "date", "host", "digest"}, httpsig.Signature, 0) 524 | err = signer.SignRequest(a.privateKey, a.publicKeyID, req, nil) 525 | if err != nil { 526 | log.Error("Can't sign the request") 527 | return "", err 528 | } 529 | 530 | resp, err := client.Do(req) 531 | if err != nil { 532 | log.Error("Cannot perform the GET request") 533 | log.Error(err) 534 | return "", err 535 | } 536 | defer resp.Body.Close() 537 | if resp.StatusCode != http.StatusOK { 538 | 539 | responseData, _ := ioutil.ReadAll(resp.Body) 540 | return "", fmt.Errorf("GET request to %s failed (%d): %s \n%s", iri.String(), resp.StatusCode, resp.Status, FormatJSON(responseData)) 541 | } 542 | 543 | responseData, _ := ioutil.ReadAll(resp.Body) 544 | fmt.Println("GET request succeeded:", iri.String(), req.Header, resp.StatusCode, resp.Status, "\n", FormatJSON(responseData)) 545 | 546 | responseText := string(responseData) 547 | return responseText, nil 548 | } 549 | 550 | // NewFollower records a new follower to the actor file 551 | func (a *Actor) NewFollower(iri string, inbox string) error { 552 | a.followers[iri] = inbox 553 | return a.save() 554 | } 555 | 556 | // appendToOutbox adds a new line with the id of the activity 557 | // to outbox.txt 558 | func (a *Actor) appendToOutbox(iri string) (err error) { 559 | // create outbox file if it doesn't exist 560 | var outbox *os.File 561 | 562 | outboxFilePath := storage + slash + "actors" + slash + a.Name + slash + "outbox.txt" 563 | outbox, err = os.OpenFile(outboxFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 564 | if err != nil { 565 | log.Info("Cannot create or open outbox file") 566 | log.Info(err) 567 | return err 568 | } 569 | defer outbox.Close() 570 | 571 | outbox.Write([]byte(iri + "\n")) 572 | 573 | return nil 574 | } 575 | 576 | // batchSend sends a batch of http posts to a list of recipients 577 | func (a *Actor) batchSend(activity map[string]interface{}, recipients []string) (err error) { 578 | for _, v := range recipients { 579 | err := a.signedHTTPPost(activity, v) 580 | if err != nil { 581 | log.Info("Failed to deliver message to " + v) 582 | } 583 | } 584 | return 585 | } 586 | 587 | // send to followers sends a batch of http posts to each one of the followers 588 | func (a *Actor) sendToFollowers(activity map[string]interface{}) (err error) { 589 | recipients := make([]string, len(a.followers)) 590 | 591 | i := 0 592 | for _, inbox := range a.followers { 593 | recipients[i] = inbox.(string) 594 | i++ 595 | } 596 | a.batchSend(activity, recipients) 597 | return 598 | } 599 | 600 | // Follow a remote user by their iri 601 | func (a *Actor) Follow(user string) (err error) { 602 | remote, err := NewRemoteActor(user) 603 | if err != nil { 604 | log.Info("Can't contact " + user + " to get their inbox") 605 | return 606 | } 607 | 608 | follow := make(map[string]interface{}) 609 | hash, id := a.newItemID() 610 | 611 | follow["@context"] = context() 612 | follow["actor"] = a.iri 613 | follow["id"] = id 614 | follow["object"] = user 615 | follow["type"] = "Follow" 616 | 617 | // if we are not already following them 618 | if _, ok := a.following[user]; !ok { 619 | // if we have not been rejected previously 620 | if _, ok := a.rejected[user]; !ok { 621 | go func() { 622 | err := a.signedHTTPPost(follow, remote.inbox) 623 | if err != nil { 624 | log.Info("Couldn't follow " + user) 625 | log.Info(err) 626 | return 627 | } 628 | // save the activity 629 | a.saveItem(hash, follow) 630 | a.requested[user] = hash 631 | a.save() 632 | // we are going to save the request here 633 | // and save the follow only on accept so look at 634 | // the http handler for the accept code 635 | }() 636 | } 637 | } 638 | 639 | return nil 640 | } 641 | 642 | // Unfollow the user declared by the iri in `user` 643 | // this recreates the original follow activity 644 | // , wraps it in an Undo activity, sets it's 645 | // id to the id of the original Follow activity that 646 | // was accepted when initially following that user 647 | // (this is read from the `actor.following` map 648 | func (a *Actor) Unfollow(user string) { 649 | // if we have a request to follow this user cancel it 650 | cancelRequest := false 651 | if _, ok := a.requested[user]; ok { 652 | log.Info("Cancelling follow request") 653 | cancelRequest = true 654 | // then continue to send the unfollow to the receipient 655 | // to inform them that the request is cancelled. 656 | } else if _, ok := a.following[user]; !ok { 657 | log.Info("We are not following this user, ignoring...") 658 | return 659 | } 660 | 661 | log.Info("Unfollowing " + user) 662 | 663 | var hash string 664 | // find the id of the original follow 665 | if cancelRequest { 666 | hash = a.requested[user].(string) 667 | } else { 668 | hash = a.following[user].(string) 669 | } 670 | 671 | // create an undo activiy 672 | undo := make(map[string]interface{}) 673 | undo["@context"] = context() 674 | undo["actor"] = a.iri 675 | undo["id"] = baseURL + "item/" + hash + "/undo" 676 | undo["type"] = "Undo" 677 | 678 | follow := make(map[string]interface{}) 679 | 680 | follow["@context"] = context() 681 | follow["actor"] = a.iri 682 | follow["id"] = baseURL + "item/" + hash 683 | follow["object"] = user 684 | follow["type"] = "Follow" 685 | 686 | // add the properties to the undo activity 687 | undo["object"] = follow 688 | 689 | // get the remote user's inbox 690 | remoteUser, err := NewRemoteActor(user) 691 | if err != nil { 692 | log.Info("Failed to contact remote actor") 693 | return 694 | } 695 | 696 | PrettyPrint(undo) 697 | go func() { 698 | err := a.signedHTTPPost(undo, remoteUser.inbox) 699 | if err != nil { 700 | log.Info("Couldn't unfollow " + user) 701 | log.Info(err) 702 | return 703 | } 704 | // if there was no error then delete the follow 705 | // from the list 706 | if cancelRequest { 707 | delete(a.requested, user) 708 | } else { 709 | delete(a.following, user) 710 | } 711 | a.save() 712 | }() 713 | } 714 | 715 | // Announce this activity to our followers 716 | func (a *Actor) Announce(url string) { 717 | // our announcements are public. Public stuff have a "To" to the url below 718 | toURL := []string{"https://www.w3.org/ns/activitystreams#Public"} 719 | hash, id := a.newItemID() 720 | 721 | announce := make(map[string]interface{}) 722 | 723 | announce["@context"] = context() 724 | announce["id"] = id 725 | announce["type"] = "Announce" 726 | announce["object"] = url 727 | announce["actor"] = a.iri 728 | announce["to"] = toURL 729 | 730 | // cc this to all our followers one by one 731 | // I've seen activities to just include the url of the 732 | // collection but for now this works. 733 | 734 | // It seems that sharedInbox will be deprecated 735 | // so this is probably a better idea anyway (#APConf) 736 | announce["cc"] = a.followersSlice() 737 | 738 | // add a timestamp 739 | announce["published"] = time.Now().Format(time.RFC3339) 740 | 741 | a.appendToOutbox(announce["id"].(string)) 742 | a.saveItem(hash, announce) 743 | a.sendToFollowers(announce) 744 | } 745 | 746 | func (a *Actor) followersSlice() []string { 747 | followersSlice := make([]string, 0) 748 | followersSlice = append(followersSlice, a.followersIRI) 749 | for k := range a.followers { 750 | followersSlice = append(followersSlice, k) 751 | } 752 | return followersSlice 753 | } 754 | 755 | // Accept a follow request 756 | func (a *Actor) Accept(follow map[string]interface{}) { 757 | // it's a follow, write it down 758 | newFollower := follow["actor"].(string) 759 | // check we aren't following ourselves 760 | if newFollower == follow["object"] { 761 | log.Info("You can't follow yourself") 762 | return 763 | } 764 | 765 | follower, err := NewRemoteActor(follow["actor"].(string)) 766 | 767 | // check if this user is already following us 768 | if _, ok := a.followers[newFollower]; ok { 769 | log.Info("You're already following us, yay!") 770 | // do nothing, they're already following us 771 | } else { 772 | a.NewFollower(newFollower, follower.inbox) 773 | } 774 | // send accept anyway even if they are following us already 775 | // this is very verbose. I would prefer creating a map by hand 776 | 777 | // remove @context from the inner activity 778 | delete(follow, "@context") 779 | 780 | accept := make(map[string]interface{}) 781 | 782 | accept["@context"] = "https://www.w3.org/ns/activitystreams" 783 | accept["to"] = follow["actor"] 784 | _, accept["id"] = a.newID() 785 | accept["actor"] = a.iri 786 | accept["object"] = follow 787 | accept["type"] = "Accept" 788 | 789 | if err != nil { 790 | log.Info("Couldn't retrieve remote actor info, maybe server is down?") 791 | log.Info(err) 792 | } 793 | 794 | // Maybe we need to save this accept? 795 | go a.signedHTTPPost(accept, follower.inbox) 796 | 797 | } 798 | 799 | // Followers returns the list of followers 800 | func (a *Actor) Followers() map[string]string { 801 | f := make(map[string]string) 802 | for follower, inbox := range a.followers { 803 | f[follower] = inbox.(string) 804 | } 805 | return f 806 | } 807 | 808 | // Following returns the list of followers 809 | func (a *Actor) Following() map[string]string { 810 | f := make(map[string]string) 811 | for followee, hash := range a.following { 812 | f[followee] = hash.(string) 813 | } 814 | return f 815 | } 816 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/writeas/activityserve 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 // indirect 7 | github.com/dchest/uniuri v1.2.0 8 | github.com/go-fed/httpsig v1.1.0 9 | github.com/gologme/log v1.3.0 10 | github.com/gorilla/mux v1.8.1 11 | github.com/writeas/go-webfinger v1.1.0 // indirect 12 | github.com/writefreely/go-nodeinfo v1.2.0 13 | gopkg.in/ini.v1 v1.67.0 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1 h1:AFSJaASPGYNbkUa5c8ZybrcW9pP3Cy7+z5dnpcc/qG8= 2 | github.com/captncraig/cors v0.0.0-20190703115713-e80254a89df1/go.mod h1:EIlIeMufZ8nqdUhnesledB15xLRl4wIJUppwDLPrdrQ= 3 | github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5 h1:RAV05c0xOkJ3dZGS0JFybxFKZ2WMLabgx3uXnd7rpGs= 4 | github.com/dchest/uniuri v0.0.0-20200228104902-7aecb25e1fe5/go.mod h1:GgB8SF9nRG+GqaDtLcwJZsQFhcogVCJ79j4EdT0c2V4= 5 | github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= 6 | github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= 7 | github.com/go-fed/httpsig v0.1.0 h1:6F2OxRVnNTN4OPN+Mc2jxs2WEay9/qiHT/jphlvAwIY= 8 | github.com/go-fed/httpsig v0.1.0/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= 9 | github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe h1:U71giCx5NjRn4Lb71UuprPHqhjxGv3Jqonb9fgcaJH8= 10 | github.com/go-fed/httpsig v0.1.1-0.20200204213531-0ef28562fabe/go.mod h1:T56HUNYZUQ1AGUzhAYPugZfp36sKApVnGBgKlIY+aIE= 11 | github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= 12 | github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= 13 | github.com/gologme/log v1.2.0 h1:Ya5Ip/KD6FX7uH0S31QO87nCCSucKtF44TLbTtO7V4c= 14 | github.com/gologme/log v1.2.0/go.mod h1:gq31gQ8wEHkR+WekdWsqDuf8pXTUZA9BnnzTuPz1Y9U= 15 | github.com/gologme/log v1.3.0 h1:l781G4dE+pbigClDSDzSaaYKtiueHCILUa/qSDsmHAo= 16 | github.com/gologme/log v1.3.0/go.mod h1:yKT+DvIPdDdDoPtqFrFxheooyVmoqi0BAsw+erN3wA4= 17 | github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc= 18 | github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= 19 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 20 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 21 | github.com/writeas/go-webfinger v1.1.0 h1:MzNyt0ry/GMsRmJGftn2o9mPwqK1Q5MLdh4VuJCfb1Q= 22 | github.com/writeas/go-webfinger v1.1.0/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc= 23 | github.com/writefreely/go-nodeinfo v1.2.0 h1:La+YbTCvmpTwFhBSlebWDDL81N88Qf/SCAvRLR7F8ss= 24 | github.com/writefreely/go-nodeinfo v1.2.0/go.mod h1:UTvE78KpcjYOlRHupZIiSEFcXHioTXuacCbHU+CAcPg= 25 | golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59 h1:hk3yo72LXLapY9EXVttc3Z1rLOxT9IuAPPX3GpY2+jo= 26 | golang.org/x/crypto v0.0.0-20180527072434-ab813273cd59/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 27 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 28 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= 29 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 30 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 31 | golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43 h1:PvnWIWTbA7gsEBkKjt0HV9hckYfcqYv8s/ju7ArZ0do= 32 | golang.org/x/sys v0.0.0-20180525142821-c11f84a56e43/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 33 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 34 | golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= 35 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 36 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 37 | gopkg.in/ini.v1 v1.55.0 h1:E8yzL5unfpW3M6fz/eB7Cb5MQAYSZ7GKo4Qth+N2sgQ= 38 | gopkg.in/ini.v1 v1.55.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 39 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 40 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 41 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package activityserve 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/gologme/log" 11 | "github.com/gorilla/mux" 12 | "github.com/writefreely/go-nodeinfo" 13 | 14 | "encoding/json" 15 | ) 16 | 17 | // ServeSingleActor just simplifies the call from main so 18 | // that onboarding is as easy as possible 19 | func ServeSingleActor(actor Actor) { 20 | Serve(map[string]Actor{actor.Name: actor}) 21 | } 22 | 23 | // Serve starts an http server with all the required handlers 24 | func Serve(actors map[string]Actor) { 25 | 26 | var webfingerHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { 27 | w.Header().Set("content-type", "application/jrd+json; charset=utf-8") 28 | account := r.URL.Query().Get("resource") // should be something like acct:user@example.com 29 | account = strings.Replace(account, "acct:", "", 1) // remove acct: 30 | server := strings.Split(baseURL, "://")[1] // remove protocol from baseURL. Should get example.com 31 | server = strings.TrimSuffix(server, "/") // remove protocol from baseURL. Should get example.com 32 | account = strings.Replace(account, "@"+server, "", 1) // remove server from handle. Should get user 33 | actor, err := LoadActor(account) 34 | // error out if this actor does not exist 35 | if err != nil { 36 | log.Info("No such actor") 37 | w.WriteHeader(http.StatusNotFound) 38 | fmt.Fprintf(w, "404 - actor not found") 39 | return 40 | } 41 | // response := `{"subject":"acct:` + actor.name + `@` + server + `","aliases":["` + baseURL + actor.name + `","` + baseURL + actor.name + `"],"links":[{"href":"` + baseURL + `","type":"text/html","rel":"https://webfinger.net/rel/profile-page"},{"href":"` + baseURL + actor.name + `","type":"application/activity+json","rel":"self"}]}` 42 | 43 | responseMap := make(map[string]interface{}) 44 | 45 | responseMap["subject"] = "acct:" + actor.Name + "@" + server 46 | // links is a json array with a single element 47 | var links [1]map[string]string 48 | link1 := make(map[string]string) 49 | link1["rel"] = "self" 50 | link1["type"] = "application/activity+json" 51 | link1["href"] = baseURL + actor.Name 52 | links[0] = link1 53 | responseMap["links"] = links 54 | 55 | response, err := json.Marshal(responseMap) 56 | if err != nil { 57 | log.Error("problem creating the webfinger response json") 58 | } 59 | PrettyPrintJSON(response) 60 | w.Write([]byte(response)) 61 | } 62 | 63 | var actorHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { 64 | w.Header().Set("content-type", "application/activity+json; charset=utf-8") 65 | log.Info("Remote server " + r.RemoteAddr + " just fetched our /actor endpoint") 66 | username := mux.Vars(r)["actor"] 67 | log.Info(username) 68 | if username == ".well-known" || username == "favicon.ico" { 69 | log.Info("well-known, skipping...") 70 | return 71 | } 72 | actor, err := LoadActor(username) 73 | // error out if this actor does not exist (or there are dots or slashes in his name) 74 | if err != nil { 75 | w.WriteHeader(http.StatusNotFound) 76 | fmt.Fprintf(w, "404 - page not found") 77 | log.Info("Can't create local actor") 78 | return 79 | } 80 | fmt.Fprintf(w, actor.whoAmI()) 81 | 82 | // Show some debugging information 83 | printer.Info("") 84 | body, _ := ioutil.ReadAll(r.Body) 85 | PrettyPrintJSON(body) 86 | log.Info(FormatHeaders(r.Header)) 87 | printer.Info("") 88 | } 89 | 90 | var outboxHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { 91 | w.Header().Set("content-type", "application/activity+json; charset=utf-8") 92 | pageStr := r.URL.Query().Get("page") // get the page from the query string as string 93 | username := mux.Vars(r)["actor"] // get the needed actor from the muxer (url variable {actor} below) 94 | actor, err := LoadActor(username) // load the actor from disk 95 | if err != nil { // either actor requested has illegal characters or 96 | log.Info("Can't load local actor") // we don't have such actor 97 | fmt.Fprintf(w, "404 - page not found") 98 | w.WriteHeader(http.StatusNotFound) 99 | return 100 | } 101 | postsPerPage := 100 102 | var response []byte 103 | filename := storage + slash + "actors" + slash + actor.Name + slash + "outbox.txt" 104 | totalLines, err := lineCounter(filename) 105 | if err != nil { 106 | log.Info("Can't read outbox.txt") 107 | log.Error(err) 108 | return 109 | } 110 | if pageStr == "" { 111 | //TODO fix total items 112 | response = []byte(`{ 113 | "@context" : "https://www.w3.org/ns/activitystreams", 114 | "first" : "` + baseURL + actor.Name + `/outbox?page=1", 115 | "id" : "` + baseURL + actor.Name + `/outbox", 116 | "last" : "` + baseURL + actor.Name + `/outbox?page=` + strconv.Itoa(totalLines/postsPerPage+1) + `", 117 | "totalItems" : ` + strconv.Itoa(totalLines) + `, 118 | "type" : "OrderedCollection" 119 | }`) 120 | } else { 121 | page, err := strconv.Atoi(pageStr) // get page number from query string 122 | if err != nil { 123 | log.Info("Page number not a number, assuming 1") 124 | page = 1 125 | } 126 | lines, err := ReadLines(filename, (page-1)*postsPerPage, page*(postsPerPage+1)-1) 127 | if err != nil { 128 | log.Info("Can't read outbox file") 129 | log.Error(err) 130 | return 131 | } 132 | responseMap := make(map[string]interface{}) 133 | responseMap["@context"] = context() 134 | responseMap["id"] = baseURL + actor.Name + "/outbox?page=" + pageStr 135 | 136 | if page*postsPerPage < totalLines { 137 | responseMap["next"] = baseURL + actor.Name + "/outbox?page=" + strconv.Itoa(page+1) 138 | } 139 | if page > 1 { 140 | responseMap["prev"] = baseURL + actor.Name + "/outbox?page=" + strconv.Itoa(page-1) 141 | } 142 | responseMap["partOf"] = baseURL + actor.Name + "/outbox" 143 | responseMap["type"] = "OrderedCollectionPage" 144 | 145 | orderedItems := make([]interface{}, 0, postsPerPage) 146 | 147 | for _, item := range lines { 148 | // split the line 149 | parts := strings.Split(item, "/") 150 | 151 | // keep the hash 152 | hash := parts[len(parts)-1] 153 | // build the filename 154 | filename := storage + slash + "actors" + slash + actor.Name + slash + "items" + slash + hash + ".json" 155 | // open the file 156 | activityJSON, err := ioutil.ReadFile(filename) 157 | if err != nil { 158 | log.Error("can't read activity") 159 | log.Info(filename) 160 | return 161 | } 162 | var temp map[string]interface{} 163 | // put it into a map 164 | json.Unmarshal(activityJSON, &temp) 165 | // append to orderedItems 166 | orderedItems = append(orderedItems, temp) 167 | } 168 | 169 | responseMap["orderedItems"] = orderedItems 170 | 171 | response, err = json.Marshal(responseMap) 172 | if err != nil { 173 | log.Info("can't marshal map to json") 174 | log.Error(err) 175 | return 176 | } 177 | } 178 | w.Write(response) 179 | } 180 | 181 | var inboxHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { 182 | b, err := ioutil.ReadAll(r.Body) 183 | if err != nil { 184 | panic(err) 185 | } 186 | activity := make(map[string]interface{}) 187 | err = json.Unmarshal(b, &activity) 188 | if err != nil { 189 | log.Error("Probably this request didn't have (valid) JSON inside it") 190 | return 191 | } 192 | // TODO check if it's actually an activity 193 | 194 | // check if case is going to be an issue 195 | switch activity["type"] { 196 | case "Follow": 197 | // load the object as actor 198 | actor, err := LoadActor(mux.Vars(r)["actor"]) // load the actor from disk 199 | if err != nil { 200 | log.Error("No such actor") 201 | return 202 | } 203 | actor.OnFollow(activity) 204 | case "Accept": 205 | acceptor := activity["actor"].(string) 206 | actor, err := LoadActor(mux.Vars(r)["actor"]) // load the actor from disk 207 | if err != nil { 208 | log.Error("No such actor") 209 | return 210 | } 211 | 212 | // From here down this could be moved to Actor (TBD) 213 | 214 | follow := activity["object"].(map[string]interface{}) 215 | id := follow["id"].(string) 216 | 217 | // check if the object of the follow is us 218 | if follow["actor"].(string) != baseURL+actor.Name { 219 | log.Info("This is not for us, ignoring") 220 | return 221 | } 222 | // try to get the hash only 223 | hash := strings.Replace(id, baseURL+actor.Name+"/item/", "", 1) 224 | // if there are still slashes in the result this means the 225 | // above didn't work 226 | if strings.ContainsAny(hash, "/") { 227 | // log.Info(follow) 228 | log.Info("The id of this follow is probably wrong") 229 | // we could return here but pixelfed returns 230 | // the id as http://domain.tld/actor instead of 231 | // http://domain.tld/actor/item/hash so this chokes 232 | // return 233 | } 234 | 235 | // Have we already requested this follow or are we following anybody that 236 | // sprays accepts? 237 | 238 | // pixelfed doesn't return the original follow thus the id is wrong so we 239 | // need to just check if we requested this actor 240 | 241 | // pixelfed doesn't return the original follow thus the id is wrong so we 242 | // need to just check if we requested this actor 243 | if _, ok := actor.requested[acceptor]; !ok { 244 | log.Info("We never requested this follow from " + acceptor + ", ignoring the Accept") 245 | return 246 | } 247 | // if pixelfed fixes https://github.com/pixelfed/pixelfed/issues/1710 we should uncomment 248 | // hash is the _ from above 249 | 250 | // if hash != id { 251 | // log.Info("Id mismatch between Follow request and Accept") 252 | // return 253 | // } 254 | actor.following[acceptor] = hash 255 | PrettyPrint(activity) 256 | delete(actor.requested, acceptor) 257 | actor.save() 258 | case "Reject": 259 | rejector := activity["actor"].(string) 260 | actor, err := LoadActor(mux.Vars(r)["actor"]) // load the actor from disk 261 | if err != nil { 262 | log.Error("No such actor") 263 | return 264 | } 265 | // write the actor to the list of rejected follows so that 266 | // we won't try following them again 267 | actor.rejected[rejector] = "" 268 | actor.save() 269 | case "Create": 270 | actor, ok := actors[mux.Vars(r)["actor"]] // load the actor from memory 271 | if !ok { 272 | log.Error("No such actor: " + mux.Vars(r)["actor"]) 273 | return 274 | } 275 | log.Info("Received the following activity from: " + r.UserAgent()) 276 | PrettyPrintJSON(b) 277 | actor.OnReceiveContent(activity) 278 | default: 279 | 280 | } 281 | } 282 | 283 | var peersHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { 284 | w.Header().Set("content-type", "application/activity+json; charset=utf-8") 285 | username := mux.Vars(r)["actor"] 286 | collection := mux.Vars(r)["peers"] 287 | if collection != "followers" && collection != "following" { 288 | w.WriteHeader(http.StatusNotFound) 289 | w.Write([]byte("404 - No such collection")) 290 | return 291 | } 292 | actor, err := LoadActor(username) 293 | // error out if this actor does not exist 294 | if err != nil { 295 | log.Errorf("Can't create local actor: %s", err) 296 | return 297 | } 298 | var page int 299 | pageS := r.URL.Query().Get("page") 300 | if pageS == "" { 301 | page = 0 302 | } else { 303 | page, err = strconv.Atoi(pageS) 304 | if err != nil { 305 | page = 1 306 | } 307 | } 308 | response, _ := actor.getPeers(page, collection) 309 | w.Write(response) 310 | } 311 | 312 | var postHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { 313 | w.Header().Set("content-type", "application/activity+json; charset=utf-8") 314 | username := mux.Vars(r)["actor"] 315 | hash := mux.Vars(r)["hash"] 316 | actor, err := LoadActor(username) 317 | // error out if this actor does not exist 318 | if err != nil { 319 | log.Errorf("Can't create local actor: %s", err) 320 | return 321 | } 322 | post, err := actor.loadItem(hash) 323 | if err != nil { 324 | w.WriteHeader(http.StatusNotFound) 325 | fmt.Fprintf(w, "404 - post not found") 326 | return 327 | } 328 | postJSON, err := json.Marshal(post) 329 | if err != nil { 330 | log.Errorf("failed to marshal json from item %s text", hash) 331 | return 332 | } 333 | w.Write(postJSON) 334 | } 335 | 336 | // Add the handlers to a HTTP server 337 | gorilla := mux.NewRouter() 338 | niCfg := nodeInfoConfig(baseURL) 339 | ni := nodeinfo.NewService(*niCfg, nodeInfoResolver{len(actors)}) 340 | gorilla.HandleFunc(nodeinfo.NodeInfoPath, http.HandlerFunc(ni.NodeInfoDiscover)) 341 | gorilla.HandleFunc(niCfg.InfoURL, http.HandlerFunc(ni.NodeInfo)) 342 | gorilla.HandleFunc("/.well-known/webfinger", webfingerHandler) 343 | gorilla.HandleFunc("/{actor}/peers/{peers}", peersHandler) 344 | gorilla.HandleFunc("/{actor}/outbox", outboxHandler) 345 | gorilla.HandleFunc("/{actor}/outbox/", outboxHandler) 346 | gorilla.HandleFunc("/{actor}/inbox", inboxHandler) 347 | gorilla.HandleFunc("/{actor}/inbox/", inboxHandler) 348 | gorilla.HandleFunc("/{actor}/", actorHandler) 349 | gorilla.HandleFunc("/{actor}", actorHandler) 350 | gorilla.HandleFunc("/{actor}/item/{hash}", postHandler) 351 | http.Handle("/", gorilla) 352 | 353 | log.Fatal(http.ListenAndServe(":8081", nil)) 354 | } 355 | -------------------------------------------------------------------------------- /nodeinfo.go: -------------------------------------------------------------------------------- 1 | package activityserve 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/writefreely/go-nodeinfo" 7 | ) 8 | 9 | type nodeInfoResolver struct { 10 | actors int 11 | } 12 | 13 | func nodeInfoConfig(baseURL string) *nodeinfo.Config { 14 | name := "Pherephone" 15 | desc := "An ActivityPub repeater." 16 | return &nodeinfo.Config{ 17 | BaseURL: baseURL, 18 | InfoURL: "/api/nodeinfo", 19 | 20 | Metadata: nodeinfo.Metadata{ 21 | NodeName: name, 22 | NodeDescription: desc, 23 | Software: nodeinfo.SoftwareMeta{ 24 | HomePage: "https://pherephone.org", 25 | GitHub: "https://github.com/writeas/pherephone", 26 | }, 27 | }, 28 | Protocols: []nodeinfo.NodeProtocol{ 29 | nodeinfo.ProtocolActivityPub, 30 | }, 31 | Services: nodeinfo.Services{ 32 | Inbound: []nodeinfo.NodeService{}, 33 | Outbound: []nodeinfo.NodeService{}, 34 | }, 35 | Software: nodeinfo.SoftwareInfo{ 36 | Name: strings.ToLower(libName), 37 | Version: version, 38 | }, 39 | } 40 | } 41 | 42 | func (r nodeInfoResolver) IsOpenRegistration() (bool, error) { 43 | return false, nil 44 | } 45 | 46 | func (r nodeInfoResolver) Usage() (nodeinfo.Usage, error) { 47 | return nodeinfo.Usage{ 48 | Users: nodeinfo.UsageUsers{ 49 | Total: r.actors, 50 | }, 51 | }, nil 52 | } 53 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # ActivityServe 2 | 3 | ## A very light ActivityPub library in go 4 | 5 | This library was built to support the very little functions that [pherephone](https://github.com/writeas/pherephone) requires. It might never be feature-complete but it's a very good point to start your activityPub journey. Take a look at [activityserve-example](https://github.com/writeas/activityserve-example) for a simple main file that uses **activityserve** to post a "Hello, world" message. 6 | 7 | For now it supports following and unfollowing users, accepting follows, announcing (boosting) other posts and this is pretty much it. 8 | 9 | The library is still a moving target and the api is not guaranteed to be stable. 10 | 11 | You can override the auto-accept upon follow by setting the `actor.OnFollow` to a custom function. -------------------------------------------------------------------------------- /remoteActor.go: -------------------------------------------------------------------------------- 1 | package activityserve 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | 10 | "github.com/gologme/log" 11 | ) 12 | 13 | // RemoteActor is a type that holds an actor 14 | // that we want to interact with 15 | type RemoteActor struct { 16 | iri, outbox, inbox, sharedInbox string 17 | url string 18 | info map[string]interface{} 19 | } 20 | 21 | // NewRemoteActor returns a remoteActor which holds 22 | // all the info required for an actor we want to 23 | // interact with (not essentially sitting in our instance) 24 | func NewRemoteActor(iri string) (RemoteActor, error) { 25 | info, err := get(iri) 26 | if err != nil { 27 | log.Info("Couldn't get remote actor information") 28 | log.Error(err) 29 | return RemoteActor{}, err 30 | } 31 | 32 | outbox, _ := info["outbox"].(string) 33 | inbox, _ := info["inbox"].(string) 34 | url, _ := info["url"].(string) 35 | var endpoints map[string]interface{} 36 | var sharedInbox string 37 | if info["endpoints"] != nil { 38 | endpoints = info["endpoints"].(map[string]interface{}) 39 | if val, ok := endpoints["sharedInbox"]; ok { 40 | sharedInbox = val.(string) 41 | } 42 | } 43 | 44 | return RemoteActor{ 45 | iri: iri, 46 | outbox: outbox, 47 | inbox: inbox, 48 | sharedInbox: sharedInbox, 49 | url: url, 50 | }, err 51 | } 52 | 53 | func (ra RemoteActor) getLatestPosts(number int) (map[string]interface{}, error) { 54 | return get(ra.outbox) 55 | } 56 | 57 | func get(iri string) (info map[string]interface{}, err error) { 58 | buf := new(bytes.Buffer) 59 | 60 | req, err := http.NewRequest("GET", iri, buf) 61 | if err != nil { 62 | log.Info(err) 63 | return 64 | } 65 | req.Header.Add("Accept", "application/activity+json") 66 | req.Header.Add("User-Agent", userAgent+" "+version) 67 | req.Header.Add("Accept-Charset", "utf-8") 68 | 69 | resp, err := client.Do(req) 70 | if err != nil { 71 | log.Info("Cannot perform the request") 72 | log.Error(err) 73 | return 74 | } 75 | 76 | responseData, _ := ioutil.ReadAll(resp.Body) 77 | 78 | if !isSuccess(resp.StatusCode) { 79 | err = fmt.Errorf("GET request to %s failed (%d): %s\nResponse: %s \nHeaders: %s", iri, resp.StatusCode, resp.Status, FormatJSON(responseData), FormatHeaders(req.Header)) 80 | log.Error(err) 81 | return 82 | } 83 | 84 | var e interface{} 85 | err = json.Unmarshal(responseData, &e) 86 | 87 | if err != nil { 88 | log.Info("something went wrong when unmarshalling the json") 89 | log.Error(err) 90 | return 91 | } 92 | info = e.(map[string]interface{}) 93 | 94 | return 95 | } 96 | 97 | // GetInbox returns the inbox url of the actor 98 | func (ra RemoteActor) GetInbox() string { 99 | return ra.inbox 100 | } 101 | 102 | // GetSharedInbox returns the inbox url of the actor 103 | func (ra RemoteActor) GetSharedInbox() string { 104 | if ra.sharedInbox == "" { 105 | return ra.inbox 106 | } 107 | return ra.sharedInbox 108 | } 109 | 110 | func (ra RemoteActor) URL() string { 111 | return ra.url 112 | } 113 | -------------------------------------------------------------------------------- /setup.go: -------------------------------------------------------------------------------- 1 | package activityserve 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/gologme/log" 9 | "gopkg.in/ini.v1" 10 | ) 11 | 12 | var slash = string(os.PathSeparator) 13 | var baseURL = "http://example.com/" 14 | var storage = "storage" 15 | var userAgent = "activityserve" 16 | var printer *log.Logger 17 | 18 | const libName = "activityserve" 19 | const version = "0.99" 20 | 21 | var client = http.Client{} 22 | 23 | // Setup sets our environment up 24 | func Setup(configurationFile string, debug bool) *ini.File { 25 | // read configuration file (config.ini) 26 | 27 | if configurationFile == "" { 28 | configurationFile = "config.ini" 29 | } 30 | 31 | cfg, err := ini.Load("config.ini") 32 | if err != nil { 33 | fmt.Printf("Fail to read file: %v", err) 34 | os.Exit(1) 35 | } 36 | 37 | // Load base url from configuration file 38 | baseURL = cfg.Section("general").Key("baseURL").String() 39 | // check if it ends with a / and append one if not 40 | if baseURL[len(baseURL)-1:] != "/" { 41 | baseURL += "/" 42 | } 43 | // print it for our users 44 | fmt.Println() 45 | fmt.Println("Domain Name:", baseURL) 46 | 47 | // Load storage location (only local filesystem supported for now) from config 48 | storage = cfg.Section("general").Key("storage").String() 49 | cwd, err := os.Getwd() 50 | fmt.Println("Storage Location:", cwd+slash+storage) 51 | fmt.Println() 52 | 53 | SetupStorage(storage) 54 | 55 | // Load user agent 56 | userAgent = cfg.Section("general").Key("userAgent").String() 57 | 58 | // I prefer long file so that I can click it in the terminal and open it 59 | // in the editor above 60 | log.SetFlags(log.Llongfile) 61 | log.EnableLevel("warn") 62 | // create a logger with levels but without prefixes for easier to read 63 | // debug output 64 | printer = log.New(os.Stdout, " ", 0) 65 | 66 | if debug == true { 67 | fmt.Println() 68 | fmt.Println("debug mode on") 69 | log.EnableLevel("info") 70 | printer.EnableLevel("info") 71 | } 72 | 73 | return cfg 74 | } 75 | 76 | // SetupStorage creates storage 77 | func SetupStorage(storage string) { 78 | // prepare storage for foreign activities (activities we store that don't 79 | // belong to us) 80 | foreignDir := storage + slash + "foreign" 81 | if _, err := os.Stat(foreignDir); os.IsNotExist(err) { 82 | os.MkdirAll(foreignDir, 0755) 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /snips.md: -------------------------------------------------------------------------------- 1 | ## When we follow someone from pherephone 1.00 2 | 3 | ``` json 4 | 5 | { 6 | "@context": "https://www.w3.org/ns/activitystreams", 7 | "actor": "https://floorb.qwazix.com/myAwesomeList1", 8 | "id": "https://floorb.qwazix.com/myAwesomeList1/Xm9UHyJXyFYduqXz", 9 | "object": "https://cybre.space/users/qwazix", 10 | "to": "https://cybre.space/users/qwazix", 11 | "type": "Follow" 12 | } 13 | ``` 14 | 15 | ``` yaml 16 | 17 | Accept: application/activity+json 18 | Accept-Charset: utf-8 19 | Date: Tue, 10 Sep 2019 05:31:22 GMT 20 | Digest: SHA-256=uL1LvGU4+gSDm8Qci6XibZODTaNCsXWXWgkMWAqBvG8= 21 | Host: cybre.space 22 | Signature: keyId="https://floorb.qwazix.com/myAwesomeList1#main-key",algorithm="rsa-sha256",headers="(request-target) date host digest",signature="c6oipeXu/2zqX3qZF1x7KLNTYifcyqwwDySoslAowjpYlKWO3qAZMU1A//trYm23AtnItXkH2mY3tPq8X7fy9P1+CMFmiTzV01MGwwwJLDtEXKoq8W7L7lWuQhDD5rjiZqWyei4T13FW7MOCRbAtC4kZqkHrp5Z3l8HhPvmgUV5VOuSGWrtbmCN3hlAEHVugQTMPC6UjlaHva6Qm/SNlFmpUdG7WmUUPJIZ6a/ysBk4cLkF1+Hb03grXKexLHAU4bPIRcjwFpUl06yp8fZ8CCLhNhIsBACiizV85D3votmdxAollE5JXSwBp4f6jrZbgiJEusFoxiVKKqZRHRESQBQ==" 23 | 24 | ``` 25 | 26 | ## Pherephone 1 Accept Activity 27 | 28 | ``` yaml 29 | Accept: application/activity+json 30 | Accept-Charset: utf-8 31 | Date: Tue, 10 Sep 2019 07:28:49 GMT 32 | Digest: SHA-256=GTy9bhYjOnbeCJzAzpqI/HEw/5p81NnoPLJkVAiZ4K0= 33 | Host: cybre.space 34 | Signature: keyId="https://floorb.qwazix.com/activityserve_test_actor_1#main-key",algorithm="rsa-sha256",headers="(request-target) date host digest",signature="jAeTEy9v1t+bCwQJB2R4Cscu/fGu5i4luHXlzJcJVyRbsHGqxbNEOxlk/G0S5BGbX3Kuoerq2oMpkFV5kCWPlpAmfhz38NKIrWhjnEUpFOfiG+ZJBpQsb3VQp7M3RGPZ9K4hmV6BSzkC8npsFGPI/HkAaj9u/txW5Cp4v6dMOYteoRLcKc3UVPK9j4hCbjq6SPhpwfM+StARSDnUFfpDe4YYQiVnO2WoINPUr4xvELmCYdBclSBCKcG66g8sBpnx4McjIlu0VISeBxzIHZYOONPteLY2uZW3Axi9JIAq88Y2Ecw4vV6Ctp7KcmD7M3kAJLqao2p/XZNZ3ExsTGfrXA==" 35 | User-Agent: activityserve 0.0 36 | ``` 37 | 38 | ``` json 39 | { 40 | "@context": "https://www.w3.org/ns/activitystreams", 41 | "actor": "https://floorb.qwazix.com/myAwesomeList1", 42 | "id": "https://floorb.qwazix.com/myAwesomeList1/SABRE7xlDAjtDcZb", 43 | "object": { 44 | "actor": "https://cybre.space/users/qwazix", 45 | "id": "https://cybre.space/3e7336af-4bcd-4f77-aa69-6a145be824aa", 46 | "object": "https://floorb.qwazix.com/myAwesomeList1", 47 | "type": "Follow" 48 | }, 49 | "to": "https://cybre.space/users/qwazix", 50 | "type": "Accept" 51 | } 52 | ``` 53 | 54 | ## Pherephone 2 Accept Activity 55 | 56 | ``` yaml 57 | 58 | Accept: application/activity+json 59 | Accept-Charset: utf-8 60 | Date: Tue, 10 Sep 2019 07:32:08 GMT 61 | Digest: SHA-256=yKzA6srSMx0b5GXn9DyflXVdqWd6ADBGt5hO9t/yc44= 62 | Host: cybre.space 63 | Signature: keyId="https://floorb.qwazix.com/myAwesomeList1#main-key",algorithm="rsa-sha256",headers="(request-target) date host digest",signature="WERXWDRFS7aGiIoz+HSujtuv9XNFBPxHkJSsCPu7PNIUDoAB2jdwW3rZc5jbrSLxi9Aqhr2BiBV/VYELQ8gITPzzIYH5sizPcPyLyARPUw37t6zA3HinahpfBKXhf73q9u+CYE/7DMKQ2Pvv2lQPaZ8hl27R2KJmcc3Jhmn5nxrQ+kxAtn6qYpNT/BqLWlXKx5rpYM2r+mHjFyYRYsjlAmi+RQNDEmv/uwn+XuNKzEtrL8Oq7mM13Lsid0a3gJi/t0b/luoyRyvi3fHUM/b1epfVogG/FulsZ0A92310v8MbastceQjjUzTzjKHILl7qNewkqtlzn2ARm3cZlAprSg==" 64 | User-Agent: pherephone (go-fed/activity v1.0.0) 65 | 66 | 67 | ``` 68 | 69 | ``` json 70 | 71 | { 72 | "@context": "https://www.w3.org/ns/activitystreams", 73 | "actor": "https://floorb.qwazix.com/activityserve_test_actor_1", 74 | "id": "https://floorb.qwazix.com/activityserve_test_actor_1/4wJ9DrBab4eIE3Bt", 75 | "object": { 76 | "actor": "https://cybre.space/users/qwazix", 77 | "id": "https://cybre.space/9123da78-21a5-44bc-bce5-4039a4072e4c", 78 | "object": "https://floorb.qwazix.com/activityserve_test_actor_1", 79 | "type": "Follow" 80 | }, 81 | "to": "https://cybre.space/users/qwazix", 82 | "type": "Accept" 83 | } 84 | 85 | ``` 86 | 87 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package activityserve 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "github.com/gologme/log" 8 | "io" 9 | "net/http" 10 | "os" 11 | ) 12 | 13 | func isSuccess(code int) bool { 14 | return code == http.StatusOK || 15 | code == http.StatusCreated || 16 | code == http.StatusAccepted || 17 | code == http.StatusNoContent 18 | } 19 | 20 | // PrettyPrint maps 21 | func PrettyPrint(themap map[string]interface{}) { 22 | b, err := json.MarshalIndent(themap, "", " ") 23 | if err != nil { 24 | log.Info("error:", err) 25 | } 26 | log.Print(string(b)) 27 | } 28 | 29 | // PrettyPrintJSON does what it's name says 30 | func PrettyPrintJSON(theJSON []byte) { 31 | dst := new(bytes.Buffer) 32 | json.Indent(dst, theJSON, "", "\t") 33 | log.Info(dst) 34 | } 35 | 36 | // FormatJSON formats json with tabs and 37 | // returns the new string 38 | func FormatJSON(theJSON []byte) string { 39 | dst := new(bytes.Buffer) 40 | json.Indent(dst, theJSON, "", "\t") 41 | return dst.String() 42 | } 43 | 44 | // FormatHeaders to string for printing 45 | func FormatHeaders(header http.Header) string { 46 | buf := new(bytes.Buffer) 47 | header.Write(buf) 48 | return buf.String() 49 | } 50 | 51 | func context() [1]string { 52 | return [1]string{"https://www.w3.org/ns/activitystreams"} 53 | } 54 | 55 | // ReadLines reads specific lines from a file and returns them as 56 | // an array of strings 57 | func ReadLines(filename string, from, to int) (lines []string, err error) { 58 | lines = make([]string, 0, to-from) 59 | reader, err := os.Open(filename) 60 | if err != nil { 61 | log.Info("could not read file") 62 | log.Info(err) 63 | return 64 | } 65 | sc := bufio.NewScanner(reader) 66 | line := 0 67 | for sc.Scan() { 68 | line++ 69 | if line >= from && line <= to { 70 | lines = append(lines, sc.Text()) 71 | } 72 | } 73 | return lines, nil 74 | } 75 | 76 | func lineCounter(filename string) (int, error) { 77 | r, err := os.Open(filename) 78 | if err != nil { 79 | log.Info("could not read file") 80 | log.Info(err) 81 | return 0, nil 82 | } 83 | buf := make([]byte, 32*1024) 84 | count := 0 85 | lineSep := []byte{'\n'} 86 | 87 | for { 88 | c, err := r.Read(buf) 89 | count += bytes.Count(buf[:c], lineSep) 90 | 91 | switch { 92 | case err == io.EOF: 93 | return count, nil 94 | 95 | case err != nil: 96 | return count, err 97 | } 98 | } 99 | } 100 | --------------------------------------------------------------------------------