<file_sep>package db

import (
	"os"
	"path"
	"testing"
	"time"
)

var testDB *SQLiteDatastore

func TestMain(m *testing.M) {
	setup()
	retCode := m.Run()
	teardown()
	os.Exit(retCode)
}

func setup() {
	os.MkdirAll(path.Join("./", "datastore"), os.ModePerm)
	testDB, _ = Create("", "LetMeIn")
	testDB.config.Init("LetMeIn")
	testDB.config.Configure(time.Now())
}

func teardown() {
	os.RemoveAll(path.Join("./", "datastore"))
}

func TestConfigDB_Create(t *testing.T) {
	if _, err := os.Stat(path.Join("./", "datastore", "mainnet.db")); os.IsNotExist(err) {
		t.Error("Failed to create database file")
	}
}

func TestConfigDB_SignIn(t *testing.T) {
	err := testDB.config.SignIn("woohoo!", "...", "...")
	if err != nil {
		t.Error(err)
	}
}

func TestConfigDB_GetUsername(t *testing.T) {
	un, err := testDB.config.GetUsername()
	if err != nil {
		t.Error(err)
		return
	}
	if un != "woohoo!" {
		t.Error("got bad username")
	}
}

func TestConfigDB_GetTokens(t *testing.T) {
	at, rt, err := testDB.config.GetTokens()
	if err != nil {
		t.Error(err)
		return
	}
	if at != "..." {
		t.Error("got bad access token")
		return
	}
	if rt != "..." {
		t.Error("got bad refresh token")
		return
	}
}

func TestConfigDB_SignOut(t *testing.T) {
	err := testDB.config.SignOut()
	if err != nil {
		t.Error(err)
		return
	}
	_, err = testDB.config.GetUsername()
	if err == nil {
		t.Error("signed out but username still present")
	}
}

func TestConfigDB_GetCreationDate(t *testing.T) {
	_, err := testDB.config.GetCreationDate()
	if err != nil {
		t.Error(err)
	}
}

func TestConfigDB_GetSchemaVersion(t *testing.T) {
	sv, err := testDB.config.GetSchemaVersion()
	if err != nil {
		t.Error(err)
	}
	if sv != schemaVersion {
		t.Error("schema version mismatch")
	}
}

func TestInterface(t *testing.T) {
	if testDB.Config() != testDB.config {
		t.Error("Config() return wrong value")
	}
	if testDB.Settings() != testDB.settings {
		t.Error("Settings() return wrong value")
	}
}

func TestConfigDB_IsEncrypted(t *testing.T) {
	encrypted := testDB.Config().IsEncrypted()
	if encrypted {
		t.Error("IsEncrypted returned incorrectly")
	}
}
<file_sep>// TODO: use lumberjack logger, not stdout, see #33
package main

import (
	"errors"
	"fmt"
	"os"
	"path/filepath"

	"github.com/fatih/color"
	"github.com/jessevdk/go-flags"
	"github.com/mitchellh/go-homedir"
	"github.com/op/go-logging"
	"gopkg.in/abiosoft/ishell.v2"

	"github.com/textileio/textile-go/cmd"
	"github.com/textileio/textile-go/core"
)

type Opts struct {
	Version bool   `short:"v" long:"version" description:"print the version number and exit"`
	DataDir string `short:"d" long:"datadir" description:"specify the data directory to be used"`
}

var Options Opts
var parser = flags.NewParser(&Options, flags.Default)

var shell *ishell.Shell

func main() {
	// create a new shell
	shell = ishell.New()

	// handle version flag
	if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") {
		shell.Println(core.Version)
		return
	}

	// handle data dir
	var dataDir string
	if len(os.Args) > 1 && (os.Args[1] == "--datadir" || os.Args[1] == "-d") {
		if len(os.Args) < 3 {
			shell.Println(errors.New("datadir option provided but missing value"))
			return
		}
		dataDir = os.Args[2]
	} else {
		hd, err := homedir.Dir()
		if err != nil {
			shell.Println(errors.New("could not determine home directory"))
			return
		}
		dataDir = filepath.Join(hd, ".textile")
	}

	// parse flags
	if _, err := parser.Parse(); err != nil {
		return
	}

	// handle interrupt
	// TODO: shutdown on 'exit' command too
	shell.Interrupt(func(c *ishell.Context, count int, input string) {
		if count == 1 {
			shell.Println("input Ctrl-C once more to exit")
			return
		}
		shell.Println("interrupted")
		shell.Printf("textile node shutting down...")
		if core.Node.Online() {
			core.Node.Stop()
		}
		shell.Printf("done\n")
		os.Exit(1)
	})

	// add commands
	shell.AddCmd(&ishell.Cmd{
		Name: "start",
		Help: "start the node",
		Func: func(c *ishell.Context) {
			if core.Node.Online() {
				c.Println("already started")
				return
			}
			if err := start(shell); err != nil {
				c.Println(fmt.Errorf("start desktop node failed: %s", err))
				return
			}
			c.Println("ok, started")
		},
	})
	shell.AddCmd(&ishell.Cmd{
		Name: "stop",
		Help: "stop the node",
		Func: func(c *ishell.Context) {
			if !core.Node.Online() {
				c.Println("already stopped")
				return
			}
			if err := stop(); err != nil {
				c.Println(fmt.Errorf("stop desktop node failed: %s", err))
				return
			}
			c.Println("ok, stopped")
		},
	})
	shell.AddCmd(&ishell.Cmd{
		Name: "id",
		Help: "show peer id",
		Func: cmd.ShowId,
	})
	shell.AddCmd(&ishell.Cmd{
		Name: "peers",
		Help: "show connected peers (same as `ipfs swarm peers`)",
		Func: cmd.SwarmPeers,
	})
	shell.AddCmd(&ishell.Cmd{
		Name: "ping",
		Help: "ping a peer (same as `ipfs ping`)",
		Func: cmd.SwarmPing,
	})
	shell.AddCmd(&ishell.Cmd{
		Name: "connect",
		Help: "connect to a peer (same as `ipfs swarm connect`)",
		Func: cmd.SwarmConnect,
	})
	{
		photoCmd := &ishell.Cmd{
			Name:     "photo",
			Help:     "manage photos",
			LongHelp: "Add, list, and get info about photos.",
		}
		photoCmd.AddCmd(&ishell.Cmd{
			Name: "add",
			Help: "add a new photo (default thread is \"#default\")",
			Func: cmd.AddPhoto,
		})
		photoCmd.AddCmd(&ishell.Cmd{
			Name: "share",
			Help: "share a photo to a different thread",
			Func: cmd.SharePhoto,
		})
		photoCmd.AddCmd(&ishell.Cmd{
			Name: "get",
			Help: "save a photo to a local file",
			Func: cmd.GetPhoto,
		})
		photoCmd.AddCmd(&ishell.Cmd{
			Name: "meta",
			Help: "cat photo metadata",
			Func: cmd.CatPhotoMetadata,
		})
		photoCmd.AddCmd(&ishell.Cmd{
			Name: "ls",
			Help: "list photos from a thread (defaults to \"#default\")",
			Func: cmd.ListPhotos,
		})
		shell.AddCmd(photoCmd)
	}
	{
		albumsCmd := &ishell.Cmd{
			Name:     "thread",
			Help:     "manage photo threads",
			LongHelp: "Add, list, enable, disable, and get info about photo threads.",
		}
		albumsCmd.AddCmd(&ishell.Cmd{
			Name: "add",
			Help: "add a new thread",
			Func: cmd.CreateAlbum,
		})
		albumsCmd.AddCmd(&ishell.Cmd{
			Name: "ls",
			Help: "list threads",
			Func: cmd.ListAlbums,
		})
		albumsCmd.AddCmd(&ishell.Cmd{
			Name: "enable",
			Help: "enable a thread",
			Func: cmd.EnableAlbum,
		})
		albumsCmd.AddCmd(&ishell.Cmd{
			Name: "disable",
			Help: "disable a thread",
			Func: cmd.DisableAlbum,
		})
		albumsCmd.AddCmd(&ishell.Cmd{
			Name: "mnemonic",
			Help: "show mnemonic phrase",
			Func: cmd.AlbumMnemonic,
		})
		albumsCmd.AddCmd(&ishell.Cmd{
			Name: "publish",
			Help: "publish latest update",
			Func: cmd.RepublishAlbum,
		})
		albumsCmd.AddCmd(&ishell.Cmd{
			Name: "peers",
			Help: "list peers",
			Func: cmd.ListAlbumPeers,
		})
		shell.AddCmd(albumsCmd)
	}

	// create and start a desktop textile node
	// TODO: darwin should use App. Support dir, not home dir
	// TODO: make api url configuratable via an option flag
	config := core.NodeConfig{
		RepoPath:      dataDir,
		CentralApiURL: "https://api.textile.io",
		IsMobile:      false,
		LogLevel:      logging.DEBUG,
		LogFiles:      true,
	}
	node, err := core.NewNode(config)
	if err != nil {
		shell.Println(fmt.Errorf("create desktop node failed: %s", err))
		return
	}
	core.Node = node

	// auto start it
	if err := start(shell); err != nil {
		shell.Println(fmt.Errorf("start desktop node failed: %s", err))
	}

	// welcome
	printSplashScreen(shell, core.Node.RepoPath)

	// run shell
	shell.Run()
}

func start(shell *ishell.Shell) error {
	// start node
	if err := core.Node.Start(); err != nil {
		return err
	}

	// start garbage collection
	// TODO: see method todo before enabling
	//go startGarbageCollection()

	// join existing rooms
	for _, album := range core.Node.Datastore.Albums().GetAlbums("") {
		cmd.JoinRoom(shell, album.Id)
	}

	return nil
}

func stop() error {
	return core.Node.Stop()
}

// Start garbage collection
func startGarbageCollection() {
	errc, err := core.Node.StartGarbageCollection()
	if err != nil {
		shell.Println(fmt.Errorf("auto gc error: %s", err))
		return
	}
	for {
		select {
		case err, ok := <-errc:
			if err != nil {
				shell.Println(fmt.Errorf("auto gc error: %s", err))
			}
			if !ok {
				shell.Println("auto gc stopped")
				return
			}
		}
	}
}

func printSplashScreen(shell *ishell.Shell, dataDir string) {
	blue := color.New(color.FgBlue).SprintFunc()
	banner :=
		`
  __                   __  .__.__          
_/  |_  ____ ___  ____/  |_|__|  |   ____  
\   __\/ __ \\  \/  /\   __\  |  | _/ __ \ 
 |  | \  ___/ >    <  |  | |  |  |_\  ___/ 
 |__|  \___  >__/\_ \ |__| |__|____/\___  >
           \/      \/                   \/ 
`
	shell.Println(blue(banner))
	shell.Println("")
	shell.Println("textile node v" + core.Version)
	shell.Printf("using repo at %s\n", dataDir)
	shell.Println("type `help` for available commands")
}
<file_sep>package repo

import (
	"database/sql"
	"time"
)

type Datastore interface {
	Config() Config
	Settings() ConfigurationStore
	Photos() PhotoStore
	Albums() AlbumStore
	Ping() error
	Close()
}

type Queryable interface {
	BeginTransaction() (*sql.Tx, error)
	PrepareQuery(string) (*sql.Stmt, error)
	PrepareAndExecuteQuery(string, ...interface{}) (*sql.Rows, error)
	ExecuteQuery(string, ...interface{}) (sql.Result, error)
}

type Config interface {
	// Create the database and tables
	Init(password string) error

	// Configure the database
	Configure(created time.Time) error

	// Saves username, access token, and refresh token
	SignIn(username string, at string, rt string) error

	// Deletes username and jwt
	SignOut() error

	// Get username
	GetUsername() (string, error)

	// Retrieve JSON web tokens
	GetTokens() (at string, rt string, err error)

	// Returns the date the seed was created
	GetCreationDate() (time.Time, error)

	// Returns current schema version of the database
	GetSchemaVersion() (string, error)

	// Returns true if the database has failed to decrypt properly ex) wrong pw
	IsEncrypted() bool
}

type ConfigurationStore interface {
	Queryable

	// Put settings to the database, overriding all fields
	Put(settings SettingsData) error

	// Update all non-nil fields
	Update(settings SettingsData) error

	// Return the settings object
	Get() (SettingsData, error)

	// Delete all settings data
	Delete() error
}

type PhotoStore interface {
	Queryable

	// Put a new photo to the database
	Put(set *PhotoSet) error

	// Get a single photo set
	GetPhoto(cid string) *PhotoSet

	// A list of photos
	GetPhotos(offsetId string, limit int, query string) []PhotoSet

	// Delete a photo
	DeletePhoto(cid string) error
}

type AlbumStore interface {
	Queryable

	// Put a new album to the database
	Put(album *PhotoAlbum) error

	// Get a single album
	GetAlbum(id string) *PhotoAlbum

	// Get a single album by name
	GetAlbumByName(name string) *PhotoAlbum

	// A list of albums
	GetAlbums(query string) []PhotoAlbum

	// Delete an album
	DeleteAlbum(id string) error
	DeleteAlbumByName(name string) error
}
<file_sep>package photos

import (
	"bytes"
	"image"
	"image/gif"
	"image/jpeg"
	"image/png"
	"os"
	"path/filepath"
	"strings"

	"github.com/disintegration/imaging"
	"github.com/rwcarlsen/goexif/exif"
)

// ImagePathWithoutExif makes a copy of image (jpg,png or gif) and applies
// all necessary operation to reverse its orientation to 1
// The result is a image with corrected orientation and without
// exif data.
func ImagePathWithoutExif(ifile *os.File) (*bytes.Buffer, error) {
	var img image.Image
	var err error
	writer := &bytes.Buffer{}
	filetype := strings.ToLower(filepath.Ext(ifile.Name()))
	// deal with image
	if filetype == ".jpg" || filetype == ".jpeg" {
		img, err = jpeg.Decode(ifile)
		if err != nil {
			return nil, err
		}
	} else if filetype == ".png" {
		img, err = png.Decode(ifile)
		if err != nil {
			return nil, err
		}
	} else if filetype == ".gif" {
		img, err = gif.Decode(ifile)
		if err != nil {
			return nil, err
		}
	}
	// deal with exif
	ifile.Seek(0, 0)
	x, err := exif.Decode(ifile)
	if err != nil {
		if x == nil {
			// ignore - image exif data has been already stripped
		}
		log.Debugf("no exif data found for [%s]: %s", ifile.Name(), err.Error())
	}
	if x != nil {
		orient, _ := x.Get(exif.Orientation)
		if orient != nil {
			log.Infof("%s had orientation %s", ifile.Name(), orient.String())
			img = reverseOrientation(img, orient.String())
		} else {
			log.Errorf("%s had no orientation - implying 1", ifile.Name())
			img = reverseOrientation(img, "1")
		}
	}
	if filetype == ".jpg" || filetype == ".jpeg" {
		err = jpeg.Encode(writer, img, nil)
		if err != nil {
			return nil, err
		}
	} else if filetype == ".png" {
		err = png.Encode(writer, img)
		if err != nil {
			return nil, err
		}
	} else if filetype == ".gif" {
		err = gif.Encode(writer, img, nil)
		if err != nil {
			return nil, err
		}
	}
	return writer, nil
}

// reverseOrientation amply`s what ever operation is necessary to transform given orientation
// to the orientation 1
func reverseOrientation(img image.Image, o string) *image.NRGBA {
	switch o {
	case "1":
		return imaging.Clone(img)
	case "2":
		return imaging.FlipV(img)
	case "3":
		return imaging.Rotate180(img)
	case "4":
		return imaging.Rotate180(imaging.FlipV(img))
	case "5":
		return imaging.Rotate270(imaging.FlipV(img))
	case "6":
		return imaging.Rotate270(img)
	case "7":
		return imaging.Rotate90(imaging.FlipV(img))
	case "8":
		return imaging.Rotate90(img)
	}
	log.Errorf("unknown orientation %s, expect 1-8", o)
	return imaging.Clone(img)
}
<file_sep>package core_test

import (
	"bytes"
	"fmt"
	"os"
	"testing"
	"time"

	"github.com/op/go-logging"
	"github.com/rwcarlsen/goexif/exif"
	"github.com/segmentio/ksuid"

	cmodels "github.com/textileio/textile-go/central/models"
	. "github.com/textileio/textile-go/core"
	util "github.com/textileio/textile-go/util/testing"
)

var node *TextileNode
var hash string

var centralReg = &cmodels.Registration{
	Username: ksuid.New().String(),
	Password: <PASSWORD>().String(),
	Identity: &cmodels.Identity{
		Type:  cmodels.EmailAddress,
		Value: ksuid.New().String() + "@textile.io",
	},
	Referral: "",
}

func TestNewNode(t *testing.T) {
	os.RemoveAll("testdata/.ipfs")
	config := NodeConfig{
		RepoPath:      "testdata/.ipfs",
		CentralApiURL: util.CentralApiURL,
		IsMobile:      false,
		LogLevel:      logging.DEBUG,
		LogFiles:      false,
	}
	var err error
	node, err = NewNode(config)
	if err != nil {
		t.Errorf("create node failed: %s", err)
	}
}

func TestTextileNode_Start(t *testing.T) {
	err := node.Start()
	if err != nil {
		t.Errorf("start node failed: %s", err)
	}
}

func TestTextileNode_StartAgain(t *testing.T) {
	err := node.Start()
	if err != ErrNodeRunning {
		t.Errorf("start node again reported wrong error: %s", err)
	}
}

func TestTextileNode_StartGarbageCollection(t *testing.T) {
	_, err := node.StartGarbageCollection()
	if err != nil {
		t.Errorf("start services failed: %s", err)
	}
}

func TestTextileNode_Online(t *testing.T) {
	if !node.Online() {
		t.Errorf("should report online")
	}
}

func TestTextileNode_SignUp(t *testing.T) {
	_, ref, err := util.CreateReferral(util.RefKey, 1)
	if err != nil {
		t.Errorf("create referral for signup failed: %s", err)
		return
	}
	if len(ref.RefCodes) == 0 {
		t.Error("create referral for signup got no codes")
		return
	}
	centralReg.Referral = ref.RefCodes[0]

	err = node.SignUp(centralReg)
	if err != nil {
		t.Errorf("signup failed: %s", err)
		return
	}
}

func TestTextileNode_SignIn(t *testing.T) {
	creds := &cmodels.Credentials{
		Username: centralReg.Username,
		Password: <PASSWORD>,
	}
	err := node.SignIn(creds)
	if err != nil {
		t.Errorf("signin failed: %s", err)
		return
	}
}

func TestTextileNode_IsSignedIn(t *testing.T) {
	// TODO
}

func TestTextileNode_GetUsername(t *testing.T) {
	// TODO
}

func TestTextileNode_GetAccessToken(t *testing.T) {
	// TODO
}

func TestTextileNode_JoinRoom(t *testing.T) {
	da := node.Datastore.Albums().GetAlbumByName("default")
	if da == nil {
		t.Error("default album not found")
		return
	}
	go node.JoinRoom(da.Id, make(chan ThreadUpdate))
}

func TestTextileNode_LeaveRoom(t *testing.T) {
	da := node.Datastore.Albums().GetAlbumByName("default")
	if da == nil {
		t.Error("default album not found")
		return
	}
	time.Sleep(time.Second)
	node.LeaveRoom(da.Id)
	<-node.LeftRoomChs[da.Id]
}

func TestTextileNode_WaitForRoom(t *testing.T) {
	// TODO
}

func TestTextileNode_CreateAlbum(t *testing.T) {
	err := node.CreateAlbum("", "test")
	if err != nil {
		t.Errorf("create album failed: %s", err)
		return
	}
}

func TestTextileNode_AddPhoto(t *testing.T) {
	caption := "i am not a crook"
	mr, err := node.AddPhoto("testdata/image.jpg", "testdata/thumb.jpg", "default", caption)
	if err != nil {
		t.Errorf("add photo failed: %s", err)
		return
	}
	if len(mr.Boundary) == 0 {
		t.Errorf("add photo got bad hash")
	}
	hash = mr.Boundary
	err = os.Remove("testdata/" + mr.Boundary)
	if err != nil {
		t.Errorf("error unlinking test multipart file: %s", err)
	}
}

func TestTexileNode_ExifRemoved(t *testing.T) {
	_, a, err := node.LoadPhotoAndAlbum(hash)
	if err != nil {
		t.Error(err.Error())
		return
	}
	pb, err := node.GetFile(fmt.Sprintf("%s/photo", hash), a)
	// create reader over photo bytes
	pr := bytes.NewReader(pb)
	// extract exif data from photo bytes
	_, err = exif.Decode(pr)
	if err == nil {
		t.Errorf("exif not scrubbed: %s", err)
		return
	}
}

func TestTextileNode_SharePhoto(t *testing.T) {
	caption := "a day that will live on in infamy"
	mr, err := node.SharePhoto(hash, "test", caption)
	if err != nil {
		t.Errorf("share photo failed: %s", err)
		return
	}
	if len(mr.Boundary) == 0 {
		t.Errorf("share photo got bad hash")
	}
}

func TestTextileNode_GetHashRequest(t *testing.T) {
	// TODO
}

func TestTextileNode_GetPhotos(t *testing.T) {
	list := node.GetPhotos("", -1, "default")
	if len(list.Hashes) == 0 {
		t.Errorf("get photos bad result")
	}
}

func TestTextileNode_GetFile(t *testing.T) {
	res, err := node.GetFile(hash+"/thumb", nil)
	if err != nil {
		t.Errorf("get photo failed: %s", err)
		return
	}
	if len(res) == 0 {
		t.Errorf("get photo bad result")
	}
}

func TestTextileNode_GetFileBase64(t *testing.T) {
	// TODO
}

func TestTextileNode_GetMetadata(t *testing.T) {
	_, err := node.GetMetaData(hash, nil)
	if err != nil {
		t.Errorf("get metadata failed: %s", err)
		return
	}
}

func TestTextileNode_GetLastHash(t *testing.T) {
	_, err := node.GetLastHash(hash, nil)
	if err != nil {
		t.Errorf("get last hash failed: %s", err)
		return
	}
}

func TestTextileNode_LoadPhotoAndAlbum(t *testing.T) {
	// TODO
}

func TestTextileNode_UnmarshalPrivatePeerKey(t *testing.T) {
	_, err := node.UnmarshalPrivatePeerKey()
	if err != nil {
		t.Errorf("unmarshal private peer key failed: %s", err)
		return
	}
}

func TestTextileNode_GetPublicPeerKeyString(t *testing.T) {
	_, err := node.GetPublicPeerKeyString()
	if err != nil {
		t.Errorf("get peer public key as base 64 string failed: %s", err)
		return
	}
}

func TestTextileNode_PingPeer(t *testing.T) {
	// TODO
}

func TestTextileNode_SignOut(t *testing.T) {
	err := node.SignOut()
	if err != nil {
		t.Errorf("signout failed: %s", err)
		return
	}
}

func TestTextileNode_Stop(t *testing.T) {
	err := node.Stop()
	if err != nil {
		t.Errorf("stop node failed: %s", err)
	}
}

func TestTextileNode_OnlineAgain(t *testing.T) {
	if node.Online() {
		t.Errorf("should report offline")
	}
}

// test signin in stopped state, should re-connect to db
func TestTextileNode_SignInAgain(t *testing.T) {
	creds := &cmodels.Credentials{
		Username: centralReg.Username,
		Password: <PASSWORD>,
	}
	err := node.SignIn(creds)
	if err != nil {
		t.Errorf("signin failed: %s", err)
		return
	}
}

func Test_Teardown(t *testing.T) {
	os.RemoveAll(node.RepoPath)
}
<file_sep>package cmd

import (
	"bytes"
	"encoding/json"
	"errors"
	"fmt"
	"image"
	_ "image/gif"
	"image/jpeg"
	_ "image/png"
	"io/ioutil"
	"os"
	"path/filepath"

	"github.com/disintegration/imaging"
	"github.com/fatih/color"
	"github.com/mitchellh/go-homedir"
	"github.com/segmentio/ksuid"
	"gopkg.in/abiosoft/ishell.v2"

	"github.com/textileio/textile-go/core"
	"github.com/textileio/textile-go/repo/photos"
)

func AddPhoto(c *ishell.Context) {
	if len(c.Args) == 0 {
		c.Err(errors.New("missing photo path"))
		return
	}

	// try to get path with home dir tilda
	pp, err := homedir.Expand(c.Args[0])
	if err != nil {
		pp = c.Args[0]
	}

	// open the file
	f, err := os.Open(pp)
	if err != nil {
		c.Err(err)
		return
	}
	defer f.Close()

	// try to create a thumbnail version
	th, _, err := image.Decode(f)
	if err != nil {
		c.Err(err)
		return
	}
	th = imaging.Resize(th, 300, 0, imaging.Lanczos)
	thb := new(bytes.Buffer)
	if err = jpeg.Encode(thb, th, nil); err != nil {
		c.Err(err)
		return
	}
	tp := filepath.Join(core.Node.RepoPath, "tmp", ksuid.New().String()+".jpg")
	if err = ioutil.WriteFile(tp, thb.Bytes(), 0644); err != nil {
		c.Err(err)
		return
	}

	// parse album
	album := "default"
	if len(c.Args) > 1 {
		album = c.Args[1]
	}

	c.Print("caption (optional): ")
	caption := c.ReadLine()

	// do the add
	f.Seek(0, 0)
	mr, err := core.Node.AddPhoto(pp, tp, album, caption)
	if err != nil {
		c.Err(err)
		return
	}

	// clean up
	if err = os.Remove(tp); err != nil {
		c.Err(err)
		return
	}
	if err = os.Remove(mr.PayloadPath); err != nil {
		c.Err(err)
		return
	}

	// show user root cid
	cyan := color.New(color.FgCyan).SprintFunc()
	c.Println(cyan("added " + mr.Boundary + " to thread " + album))
}

func SharePhoto(c *ishell.Context) {
	if len(c.Args) == 0 {
		c.Err(errors.New("missing photo cid"))
		return
	}
	if len(c.Args) == 1 {
		c.Err(errors.New("missing destination thread name"))
		return
	}
	cid := c.Args[0]
	dest := c.Args[1]

	c.Print("caption (optional): ")
	caption := c.ReadLine()

	mr, err := core.Node.SharePhoto(cid, dest, caption)
	if err != nil {
		c.Err(err)
		return
	}

	green := color.New(color.FgHiGreen).SprintFunc()
	c.Println(green("shared " + cid + " to thread " + dest + " (new cid: " + mr.Boundary + ")"))
}

func ListPhotos(c *ishell.Context) {
	album := "default"
	if len(c.Args) > 0 {
		album = c.Args[0]
	}

	a := core.Node.Datastore.Albums().GetAlbumByName(album)
	if a == nil {
		c.Err(errors.New(fmt.Sprintf("could not find thread: %s", album)))
		return
	}

	sets := core.Node.Datastore.Photos().GetPhotos("", -1, "album='"+a.Id+"'")
	if len(sets) == 0 {
		c.Println(fmt.Sprintf("no photos found in: %s", album))
	} else {
		c.Println(fmt.Sprintf("found %v photos in: %s", len(sets), album))
	}

	magenta := color.New(color.FgHiMagenta).SprintFunc()
	for _, s := range sets {
		c.Println(magenta(fmt.Sprintf("cid: %s, name: %s%s", s.Cid, s.MetaData.Name, s.MetaData.Ext)))
	}
}

func GetPhoto(c *ishell.Context) {
	if len(c.Args) == 0 {
		c.Err(errors.New("missing photo cid"))
		return
	}
	if len(c.Args) == 1 {
		c.Err(errors.New("missing out directory"))
		return
	}
	hash := c.Args[0]

	// try to get path with home dir tilda
	dest, err := homedir.Expand(c.Args[1])
	if err != nil {
		dest = c.Args[1]
	}

	set, a, err := core.Node.LoadPhotoAndAlbum(hash)
	if err != nil {
		c.Err(err)
		return
	}

	pb, err := core.Node.GetFile(fmt.Sprintf("%s/photo", hash), a)
	if err != nil {
		c.Err(err)
		return
	}

	path := filepath.Join(dest, set.MetaData.Name+set.MetaData.Ext)
	if err := ioutil.WriteFile(path, pb, 0644); err != nil {
		c.Err(err)
		return
	}

	blue := color.New(color.FgHiBlue).SprintFunc()
	c.Println(blue("wrote " + hash + " to " + path))
}

func CatPhotoMetadata(c *ishell.Context) {
	if len(c.Args) == 0 {
		c.Err(errors.New("missing photo cid"))
		return
	}
	hash := c.Args[0]

	b, err := core.Node.GetFile(fmt.Sprintf("%s/meta", hash), nil)
	if err != nil {
		c.Err(err)
		return
	}
	var md photos.Metadata
	if err := json.Unmarshal(b, &md); err != nil {
		c.Err(err)
		return
	}
	jb, err := json.MarshalIndent(md, "", "    ")
	if err != nil {
		c.Err(err)
		return
	}

	black := color.New(color.FgHiBlack).SprintFunc()
	c.Println(black(string(jb)))
}
<file_sep>package repo

import (
	"github.com/textileio/textile-go/repo/photos"

	libp2p "gx/ipfs/QmaPbCnUMBohSGo3KnxEa2bHqyJVVeEEcwtqJAYxerieBo/go-libp2p-crypto"
)

type SettingsData struct {
	Version *string `json:"version"`
}

type PhotoSet struct {
	Cid      string          `json:"cid"`
	LastCid  string          `json:"last_cid"`
	AlbumID  string          `json:"album_id"`
	MetaData photos.Metadata `json:"metadata"`
	Caption  string          `json:"caption,omitempty"`
	IsLocal  bool            `json:"is_local"`
}

type PhotoAlbum struct {
	Id       string         `json:"id"`
	Key      libp2p.PrivKey `json:"key"`
	Mnemonic string         `json:"mnemonic"`
	Name     string         `json:"name"`
}
<file_sep>package db

import (
	"database/sql"
	"encoding/json"
	"sync"
	"testing"

	"github.com/textileio/textile-go/repo"
)

var sdb repo.ConfigurationStore
var settings repo.SettingsData

func init() {
	conn, _ := sql.Open("sqlite3", ":memory:")
	initDatabaseTables(conn, "")
	sdb = NewConfigurationStore(conn, new(sync.Mutex))
	v := "/textile-go:0.1/"
	settings = repo.SettingsData{
		Version: &v,
	}
}

func TestSettingsPut(t *testing.T) {
	err := sdb.Put(settings)
	if err != nil {
		t.Error(err)
	}
	set := repo.SettingsData{}
	stmt, err := sdb.PrepareQuery("select value from config where key=?")
	defer stmt.Close()
	var settingsBytes []byte
	err = stmt.QueryRow("settings").Scan(&settingsBytes)
	if err != nil {
		t.Error(err)
	}
	err = json.Unmarshal(settingsBytes, &set)
	if err != nil {
		t.Error(err)
	}
	if *set.Version != "/textile-go:0.1/" {
		t.Error("Settings put failed to put correct value")
	}
}

func TestInvalidSettingsGet(t *testing.T) {
	tx, err := sdb.BeginTransaction()
	if err != nil {
		t.Error(err)
	}
	stmt, _ := tx.Prepare("insert or replace into config(key, value) values(?,?)")
	defer stmt.Close()

	_, err = stmt.Exec("settings", string("Test fail"))
	if err != nil {
		tx.Rollback()
		t.Error(err)
	}
	tx.Commit()
	_, err = sdb.Get()
	if err == nil {
		t.Error("settings get didn't error with invalid data")
	}
}

func TestSettingsGet(t *testing.T) {
	err := sdb.Put(settings)
	if err != nil {
		t.Error(err)
	}
	set, err := sdb.Get()
	if err != nil {
		t.Error(err)
	}
	if *set.Version != "/textile-go:0.1/" {
		t.Error("Settings put failed to put correct value")
	}
}

func TestSettingsUpdate(t *testing.T) {
	err := sdb.Put(settings)
	if err != nil {
		t.Error(err)
	}
	l := "/textile-go:0.2/"
	setUpdt := repo.SettingsData{
		Version: &l,
	}
	err = sdb.Update(setUpdt)
	if err != nil {
		t.Error(err)
	}
	set, err := sdb.Get()
	if err != nil {
		t.Error(err)
	}
	if *set.Version != "/textile-go:0.2/" {
		t.Error("Settings update failed to put correct value")
	}
}
<file_sep>package core

import (
	"bytes"
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"sort"
	"strings"

	"gx/ipfs/Qm<KEY>/go-ipfs/core"
	"gx/ipfs/<KEY>/go-ipfs/core/corerepo"

	iaddr "gx/ipfs/Qm<KEY>/go-ipfs-addr"
	"gx/ipfs/<KEY>/go-ipfs-cmds"
	ma "gx/ipfs/Qm<KEY>/go-multiaddr"
	pstore "gx/ipfs/Qm<KEY>/go-libp2p-peerstore"
	"gx/ipfs/Qm<KEY>/go-libp2p-peer"
	libp2p "gx/ipfs/Q<KEY>/go-libp2p-crypto"
)

// start auto-garbage collection process
// TODO: investigate where this is gonna get datadir from
// TODO: it may use the env var IPFS_PATH, which we don't want
func runGC(ctx context.Context, node *core.IpfsNode) (<-chan error, error) {
	errc := make(chan error)
	go func() {
		errc <- corerepo.PeriodicGC(ctx, node)
		close(errc)
	}()
	log.Info("auto garbage collection started")

	return errc, nil
}

// createMnemonic creates a new mnemonic phrase with given entropy
func createMnemonic(newEntropy func(int) ([]byte, error), newMnemonic func([]byte) (string, error)) (string, error) {
	entropy, err := newEntropy(256)
	if err != nil {
		return "", err
	}
	mnemonic, err := newMnemonic(entropy)
	if err != nil {
		return "", err
	}
	return mnemonic, nil
}

// identityKeyFromSeed returns a new key identity from a seed
func identityKeyFromSeed(seed []byte, bits int) ([]byte, error) {
	hm := hmac.New(sha256.New, []byte("sc<PASSWORD>"))
	hm.Write(seed)
	reader := bytes.NewReader(hm.Sum(nil))
	sk, _, err := libp2p.GenerateKeyPairWithReader(libp2p.Ed25519, bits, reader)
	if err != nil {
		return nil, err
	}
	encodedKey, err := sk.Bytes()
	if err != nil {
		return nil, err
	}
	return encodedKey, nil
}

// printSwarmAddrs prints the addresses of the host
func printSwarmAddrs(node *core.IpfsNode) error {
	if !node.OnlineMode() {
		log.Info("swarm not listening, running in offline mode")
		return nil
	}

	var lisAddrs []string
	ifaceAddrs, err := node.PeerHost.Network().InterfaceListenAddresses()
	if err != nil {
		return err
	}
	for _, addr := range ifaceAddrs {
		lisAddrs = append(lisAddrs, addr.String())
	}
	sort.Sort(sort.StringSlice(lisAddrs))
	for _, addr := range lisAddrs {
		log.Infof("swarm listening on %s\n", addr)
	}

	var addrs []string
	for _, addr := range node.PeerHost.Addrs() {
		addrs = append(addrs, addr.String())
	}
	sort.Sort(sort.StringSlice(addrs))
	for _, addr := range addrs {
		log.Infof("swarm announcing %s\n", addr)
	}

	return nil
}

// parsePeerParam takes a peer address string and returns p2p params
func parsePeerParam(text string) (ma.Multiaddr, peer.ID, error) {
	// to be replaced with just multiaddr parsing, once ptp is a multiaddr protocol
	idx := strings.LastIndex(text, "/")
	if idx == -1 {
		pid, err := peer.IDB58Decode(text)
		if err != nil {
			return nil, "", err
		}

		return nil, pid, nil
	}

	addrS := text[:idx]
	peeridS := text[idx+1:]

	var maddr ma.Multiaddr
	var pid peer.ID

	// make sure addrS parses as a multiaddr.
	if len(addrS) > 0 {
		var err error
		maddr, err = ma.NewMultiaddr(addrS)
		if err != nil {
			return nil, "", err
		}
	}

	// make sure idS parses as a peer.ID
	var err error
	pid, err = peer.IDB58Decode(peeridS)
	if err != nil {
		return nil, "", err
	}

	return maddr, pid, nil
}

// parseAddresses is a function that takes in a slice of string peer addresses
// (multiaddr + peerid) and returns slices of multiaddrs and peerids.
func parseAddresses(addrs []string) (iaddrs []iaddr.IPFSAddr, err error) {
	iaddrs = make([]iaddr.IPFSAddr, len(addrs))
	for i, saddr := range addrs {
		iaddrs[i], err = iaddr.ParseString(saddr)
		if err != nil {
			return nil, cmds.ClientError("invalid peer address: " + err.Error())
		}
	}
	return
}

// peersWithAddresses is a function that takes in a slice of string peer addresses
// (multiaddr + peerid) and returns a slice of properly constructed peers
func peersWithAddresses(addrs []string) (pis []pstore.PeerInfo, err error) {
	iaddrs, err := parseAddresses(addrs)
	if err != nil {
		return nil, err
	}

	for _, a := range iaddrs {
		pis = append(pis, pstore.PeerInfo{
			ID:    a.ID(),
			Addrs: []ma.Multiaddr{a.Transport()},
		})
	}
	return pis, nil
}
<file_sep>package db

import (
	"database/sql"
	"encoding/json"
	"errors"
	"sync"

	"github.com/textileio/textile-go/repo"
)

var SettingsNotSetError error = errors.New("settings not set")

type SettingsDB struct {
	modelStore
}

func NewConfigurationStore(db *sql.DB, lock *sync.Mutex) repo.ConfigurationStore {
	return &SettingsDB{modelStore{db, lock}}
}

func (s *SettingsDB) Put(settings repo.SettingsData) error {
	s.lock.Lock()
	defer s.lock.Unlock()
	tx, err := s.db.Begin()
	if err != nil {
		return err
	}
	b, err := json.MarshalIndent(&settings, "", "    ")
	if err != nil {
		return err
	}
	stmt, err := tx.Prepare("insert or replace into config(key, value) values(?,?)")
	if err != nil {
		return err
	}
	defer stmt.Close()

	_, err = stmt.Exec("settings", string(b))
	if err != nil {
		tx.Rollback()
		return err
	}
	tx.Commit()
	return nil
}

func (s *SettingsDB) Get() (repo.SettingsData, error) {
	s.lock.Lock()
	defer s.lock.Unlock()
	settings := repo.SettingsData{}
	stmt, err := s.db.Prepare("select value from config where key=?")
	if err != nil {
		return settings, err
	}
	defer stmt.Close()
	var settingsBytes []byte
	err = stmt.QueryRow("settings").Scan(&settingsBytes)
	if err != nil {
		return settings, SettingsNotSetError
	}
	err = json.Unmarshal(settingsBytes, &settings)
	if err != nil {
		return settings, err
	}
	return settings, nil
}

func (s *SettingsDB) Update(settings repo.SettingsData) error {
	current, err := s.Get()
	if err != nil {
		return errors.New("not found")
	}
	if settings.Version == nil {
		settings.Version = current.Version
	}
	err = s.Put(settings)
	if err != nil {
		return err
	}
	return nil
}

// Delete removes all settings from the database. It's a destructive action that should be used with care.
func (s *SettingsDB) Delete() error {
	s.lock.Lock()
	defer s.lock.Unlock()

	stmt, err := s.db.Prepare("delete from config where key = ?")
	if err != nil {
		return err
	}
	defer stmt.Close()

	_, err = stmt.Exec("settings")

	return err
}
<file_sep>package photos

import (
	"bytes"
	"encoding/json"
	"io"
	"io/ioutil"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/op/go-logging"

	"github.com/rwcarlsen/goexif/exif"
	"github.com/textileio/textile-go/net"

	"gx/ipfs/Qm<KEY>/go-ipfs/core"
	"gx/ipfs/Qm<KEY>/go-ipfs/core/coreunix"
	uio "gx/ipfs/<KEY>/go-ipfs/unixfs/io"

	libp2p "gx/ipfs/Q<KEY>/go-libp2p-crypto"
	"gx/ipfs/Q<KEY>/go-cid"
	ipld "gx/ipfs/Q<KEY>ES/go-ipld-format"
)

var log = logging.MustGetLogger("photos")

type Metadata struct {
	// photo data
	Name      string    `json:"name,omitempty"`
	Ext       string    `json:"extension,omitempty"`
	Created   time.Time `json:"created,omitempty"`
	Added     time.Time `json:"added,omitempty"`
	Latitude  float64   `json:"latitude,omitempty"`
	Longitude float64   `json:"longitude,omitempty"`

	// user data
	Username string `json:"username,omitempty"`
	PeerID   string `json:"peer_id,omitempty"`
}

// Add adds a photo, it's thumbnail, and it's metadata to ipfs
func Add(n *core.IpfsNode, pk libp2p.PubKey, p *os.File, t *os.File, lc string, un string, cap string) (*net.MultipartRequest, *Metadata, error) {
	// path info
	path := p.Name()
	ext := strings.ToLower(filepath.Ext(path))
	dname := filepath.Dir(t.Name())

	// try to extract exif data
	// TODO: get image size info
	// TODO: break this up into one method with multi sub-methods for testing
	var tm time.Time
	x, err := exif.Decode(p)
	if err == nil {
		// time taken
		tmTmp, err := x.DateTime()
		if err == nil {
			tm = tmTmp
		}
	}
	// create a metadata file
	md := &Metadata{
		Name:     strings.TrimSuffix(filepath.Base(path), ext),
		Ext:      ext,
		Username: un,
		PeerID:   n.Identity.Pretty(),
		Created:  tm,
		Added:    time.Now(),
	}
	mdb, err := json.Marshal(md)
	if err != nil {
		return nil, nil, err
	}
	cmdb, err := net.Encrypt(pk, mdb)
	if err != nil {
		return nil, nil, err
	}

	// encrypt the last hash
	clcb, err := net.Encrypt(pk, []byte(lc))
	if err != nil {
		return nil, nil, err
	}

	// encrypt the caption
	ccapb, err := net.Encrypt(pk, []byte(cap))
	if err != nil {
		return nil, nil, err
	}

	// create an empty virtual directory
	dirb := uio.NewDirectory(n.DAG)

	// add the image
	p.Seek(0, 0)
	pr, err := ImagePathWithoutExif(p)
	if err != nil {
		return nil, nil, err
	}
	pb, err := getEncryptedReaderBytes(pr, pk)
	if err != nil {
		return nil, nil, err
	}
	err = addFileToDirectory(n, dirb, pb, "photo")
	if err != nil {
		return nil, nil, err
	}

	// add the thumbnail
	tr, err := ImagePathWithoutExif(t)
	if err != nil {
		return nil, nil, err
	}

	tb, err := getEncryptedReaderBytes(tr, pk)
	if err != nil {
		return nil, nil, err
	}
	err = addFileToDirectory(n, dirb, tb, "thumb")
	if err != nil {
		return nil, nil, err
	}

	// add the metadata file
	err = addFileToDirectory(n, dirb, cmdb, "meta")
	if err != nil {
		return nil, nil, err
	}

	// add caption
	err = addFileToDirectory(n, dirb, ccapb, "caption")
	if err != nil {
		return nil, nil, err
	}

	// add last update's cid
	err = addFileToDirectory(n, dirb, clcb, "last")
	if err != nil {
		return nil, nil, err
	}

	// pin it
	dir, err := dirb.GetNode()
	if err != nil {
		return nil, nil, err
	}
	if err := pinPhoto(n, dir); err != nil {
		return nil, nil, err
	}

	// create and init a new multipart request
	mr := &net.MultipartRequest{}
	mr.Init(dname, dir.Cid().Hash().B58String())

	// add files
	if err := mr.AddFile(pb, "photo"); err != nil {
		return nil, nil, err
	}
	if err := mr.AddFile(tb, "thumb"); err != nil {
		return nil, nil, err
	}
	if err := mr.AddFile(cmdb, "meta"); err != nil {
		return nil, nil, err
	}
	if err := mr.AddFile(ccapb, "caption"); err != nil {
		return nil, nil, err
	}
	if err := mr.AddFile(clcb, "last"); err != nil {
		return nil, nil, err
	}

	// finish request
	if err := mr.Finish(); err != nil {
		return nil, nil, err
	}

	return mr, md, nil
}

// getEncryptedReaderBytes reads reader bytes and returns the encrypted result
func getEncryptedReaderBytes(r io.Reader, pk libp2p.PubKey) ([]byte, error) {
	b, err := ioutil.ReadAll(r)
	if err != nil {
		return nil, err
	}
	return net.Encrypt(pk, b)
}

// addFileToDirectory adds bytes as file to a virtual directory (dag) structure
func addFileToDirectory(n *core.IpfsNode, dirb *uio.Directory, b []byte, fname string) error {
	r := bytes.NewReader(b)
	s, err := coreunix.Add(n, r)
	if err != nil {
		return err
	}
	c, err := cid.Decode(s)
	if err != nil {
		return err
	}
	node, err := n.DAG.Get(n.Context(), c)
	if err != nil {
		return err
	}
	if err := dirb.AddChild(n.Context(), fname, node); err != nil {
		return err
	}
	return nil
}

// pinPhoto pins the entire photo set dag, minues the full res image
func pinPhoto(n *core.IpfsNode, dir ipld.Node) error {
	// pin the top-level structure (recursive: false)
	if err := n.Pinning.Pin(n.Context(), dir, false); err != nil {
		return err
	}
	// pin thumbnail and metadata
	for _, item := range dir.Links() {
		if item.Name == "meta" || item.Name == "thumb" {
			tnode, err := item.GetNode(n.Context(), n.DAG)
			if err != nil {
				return err
			}
			n.Pinning.Pin(n.Context(), tnode, false)
		}
	}
	return n.Pinning.Flush()
}
<file_sep>package main

import (
	"flag"
	"fmt"

	"github.com/asticode/go-astilectron"
	"github.com/asticode/go-astilectron-bootstrap"
	"github.com/asticode/go-astilog"
	"github.com/op/go-logging"
	"github.com/pkg/errors"

	"github.com/textileio/textile-go/core"
)

var (
	BuiltAt string
	debug   = flag.Bool("d", false, "enables the debug mode")
	w       *astilectron.Window
)

var textile *core.TextileNode
var gateway string

func main() {
	AppName := "Textile"
	flag.Parse()
	astilog.FlagInit()

	// create a desktop textile node
	// TODO: on darwin, repo should live in Application Support
	// TODO: make api url configurable somehow
	config := core.NodeConfig{
		RepoPath:      "output/.ipfs",
		CentralApiURL: "https://api.textile.io",
		IsMobile:      false,
		LogLevel:      logging.DEBUG,
		LogFiles:      true,
	}
	var err error
	textile, err = core.NewNode(config)
	if err != nil {
		astilog.Errorf("create desktop node failed: %s", err)
		return
	}

	// bring the node online and startup the gateway
	err = textile.Start()
	if err != nil {
		astilog.Errorf("start desktop node failed: %s", err)
		return
	}

	// save off the gateway address
	gateway = fmt.Sprintf("http://localhost%s", textile.GatewayProxy.Addr)

	// start garbage collection and gateway services
	// TODO: see method todo before enabling
	//errc, err := textile.StartGarbageCollection()
	//if err != nil {
	//	astilog.Errorf("auto gc error: %s", err)
	//	return
	//}
	//go func() {
	//	for {
	//		select {
	//		case err, ok := <-errc:
	//			if err != nil {
	//				astilog.Errorf("auto gc error: %s", err)
	//			}
	//			if !ok {
	//				astilog.Info("auto gc stopped")
	//				return
	//			}
	//		}
	//	}
	//}()

	// run bootstrap
	astilog.Debugf("Running app built at %s", BuiltAt)
	if err := bootstrap.Run(bootstrap.Options{
		Asset: Asset,
		AstilectronOptions: astilectron.Options{
			AppName:            AppName,
			AppIconDarwinPath:  "resources/icon.icns",
			AppIconDefaultPath: "resources/icon.png",
			// TODO: Revisit: slightly dangerous because this will ignore _all_ certificate errors
			ElectronSwitches: []string{"ignore-certificate-errors", "true"},
		},

		Debug:          *debug,
		Homepage:       "index.html",
		MessageHandler: handleMessages,
		OnWait:         start,
		RestoreAssets:  RestoreAssets,
		WindowOptions: &astilectron.WindowOptions{
			BackgroundColor: astilectron.PtrStr("#333333"),
			Center:          astilectron.PtrBool(true),
			Height:          astilectron.PtrInt(633),
			Width:           astilectron.PtrInt(1024),
		},
	}); err != nil {
		astilog.Fatal(errors.Wrap(err, "running bootstrap failed"))
	}
}
<file_sep>package db

import (
	"database/sql"
	"sync"

	"github.com/textileio/textile-go/repo"

	libp2p "gx/ipfs/QmaPbCnUMBohSGo3KnxEa2bHqyJVVeEEcwtqJAYxerieBo/go-libp2p-crypto"
)

type AlbumDB struct {
	modelStore
}

func NewAlbumStore(db *sql.DB, lock *sync.Mutex) repo.AlbumStore {
	return &AlbumDB{modelStore{db, lock}}
}

func (c *AlbumDB) Put(album *repo.PhotoAlbum) error {
	c.lock.Lock()
	defer c.lock.Unlock()

	tx, err := c.db.Begin()
	if err != nil {
		return err
	}
	stm := `insert into albums(id, key, mnemonic, name) values(?,?,?,?)`
	stmt, err := tx.Prepare(stm)
	if err != nil {
		log.Errorf("error in tx prepare: %s", err)
		return err
	}

	skb, err := album.Key.Bytes()
	if err != nil {
		log.Errorf("error getting key bytes: %s", err)
		return err
	}

	defer stmt.Close()
	_, err = stmt.Exec(
		album.Id,
		skb,
		album.Mnemonic,
		album.Name,
	)
	if err != nil {
		tx.Rollback()
		log.Errorf("error in db exec: %s", err)
		return err
	}
	tx.Commit()
	return nil
}

func (c *AlbumDB) GetAlbum(id string) *repo.PhotoAlbum {
	c.lock.Lock()
	defer c.lock.Unlock()
	ret := c.handleQuery("select * from albums where id='" + id + "';")
	if len(ret) == 0 {
		return nil
	}
	return &ret[0]
}

func (c *AlbumDB) GetAlbumByName(name string) *repo.PhotoAlbum {
	c.lock.Lock()
	defer c.lock.Unlock()
	ret := c.handleQuery("select * from albums where name='" + name + "';")
	if len(ret) == 0 {
		return nil
	}
	return &ret[0]
}

func (c *AlbumDB) GetAlbums(query string) []repo.PhotoAlbum {
	c.lock.Lock()
	defer c.lock.Unlock()
	q := ""
	if query != "" {
		q = " where " + query
	}
	return c.handleQuery("select * from albums" + q + ";")
}

func (c *AlbumDB) DeleteAlbum(id string) error {
	c.lock.Lock()
	defer c.lock.Unlock()
	_, err := c.db.Exec("delete from albums where id=?", id)
	return err
}

func (c *AlbumDB) DeleteAlbumByName(name string) error {
	c.lock.Lock()
	defer c.lock.Unlock()
	_, err := c.db.Exec("delete from albums where name=?", name)
	return err
}

func (c *AlbumDB) handleQuery(stm string) []repo.PhotoAlbum {
	var ret []repo.PhotoAlbum
	rows, err := c.db.Query(stm)
	if err != nil {
		log.Errorf("error in db query: %s", err)
		return nil
	}
	for rows.Next() {
		var id, mnemonic, name string
		var key []byte
		if err := rows.Scan(&id, &key, &mnemonic, &name); err != nil {
			log.Errorf("error in db scan: %s", err)
			continue
		}

		sk, err := libp2p.UnmarshalPrivateKey(key)
		if err != nil {
			log.Errorf("error unmarshaling private key: %s", err)
			continue
		}

		album := repo.PhotoAlbum{
			Id:       id,
			Key:      sk,
			Mnemonic: mnemonic,
			Name:     name,
		}
		ret = append(ret, album)
	}
	return ret
}
<file_sep>package cmd

import (
	"github.com/fatih/color"
	"gopkg.in/abiosoft/ishell.v2"

	"github.com/textileio/textile-go/core"
	"gx/<KEY>go-libp2p-peer"
)

func ShowId(c *ishell.Context) {
	// peer id
	psk, err := core.Node.UnmarshalPrivatePeerKey()
	if err != nil {
		c.Err(err)
		return
	}
	pid, err := peer.IDFromPrivateKey(psk)
	if err != nil {
		c.Err(err)
		return
	}

	// show user their id
	red := color.New(color.FgRed).SprintFunc()
	c.Println(red(pid.Pretty()))
}
<file_sep>package db

import (
	"database/sql"
	"sync"
	"testing"
	"time"

	"github.com/textileio/textile-go/repo"
	"github.com/textileio/textile-go/repo/photos"
)

var phdb repo.PhotoStore

func init() {
	setupPhotoDB()
}

func setupPhotoDB() {
	conn, _ := sql.Open("sqlite3", ":memory:")
	initDatabaseTables(conn, "")
	phdb = NewPhotoStore(conn, new(sync.Mutex))
}

func TestPhotoDB_Put(t *testing.T) {
	err := phdb.Put(&repo.PhotoSet{
		Cid:      "Qmabc123",
		LastCid:  "",
		AlbumID:  "Qm",
		MetaData: photos.Metadata{},
		Caption:  "tear down this wall",
		IsLocal:  false,
	})
	if err != nil {
		t.Error(err)
	}
	stmt, err := phdb.PrepareQuery("select cid from photos where cid=?")
	defer stmt.Close()
	var cid string
	err = stmt.QueryRow("Qmabc123").Scan(&cid)
	if err != nil {
		t.Error(err)
	}
	if cid != "Qmabc123" {
		t.Errorf(`expected "Qmabc123" got %s`, cid)
	}
}

func TestPhotoDB_GetPhoto(t *testing.T) {
	setupPhotoDB()
	err := phdb.Put(&repo.PhotoSet{
		Cid:     "Qmabc",
		LastCid: "",
		AlbumID: "Qm",
		MetaData: photos.Metadata{
			Added: time.Now(),
		},
		Caption: "one small step for man",
		IsLocal: true,
	})
	if err != nil {
		t.Error(err)
	}
	p := phdb.GetPhoto("Qmabc")
	if p == nil {
		t.Error("could not get photo")
		return
	}
}

func TestPhotoDB_GetPhotos(t *testing.T) {
	setupPhotoDB()
	err := phdb.Put(&repo.PhotoSet{
		Cid:     "Qm123",
		LastCid: "",
		AlbumID: "Qm",
		MetaData: photos.Metadata{
			Added: time.Now(),
		},
		Caption: "one giant leap for mankind",
		IsLocal: true,
	})
	if err != nil {
		t.Error(err)
	}
	time.Sleep(time.Second * 1)
	err = phdb.Put(&repo.PhotoSet{
		Cid:     "Qm456",
		LastCid: "Qm123",
		AlbumID: "Qm",
		MetaData: photos.Metadata{
			Added: time.Now(),
		},
		IsLocal: false,
	})
	if err != nil {
		t.Error(err)
	}
	ps := phdb.GetPhotos("", -1, "")
	if len(ps) != 2 {
		t.Error("returned incorrect number of photos")
		return
	}

	limited := phdb.GetPhotos("", 1, "")
	if len(limited) != 1 {
		t.Error("returned incorrect number of photos")
		return
	}

	offset := phdb.GetPhotos(limited[0].Cid, -1, "")
	if len(offset) != 1 {
		t.Error("returned incorrect number of photos")
		return
	}

	filtered := phdb.GetPhotos("", -1, "local=1")
	if len(filtered) != 1 {
		t.Error("returned incorrect number of photos")
		return
	}
}

func TestPhotoDB_DeletePhoto(t *testing.T) {
	setupPhotoDB()
	err := phdb.Put(&repo.PhotoSet{
		Cid:      "Qm789",
		LastCid:  "",
		AlbumID:  "Qm",
		MetaData: photos.Metadata{},
		Caption:  "",
		IsLocal:  true,
	})
	if err != nil {
		t.Error(err)
	}
	ps := phdb.GetPhotos("", -1, "")
	if len(ps) == 0 {
		t.Error("returned incorrect number of photos")
		return
	}
	err = phdb.DeletePhoto(ps[0].Cid)
	if err != nil {
		t.Error(err)
	}
	stmt, err := phdb.PrepareQuery("select cid from photos where cid=?")
	defer stmt.Close()
	var cid string
	err = stmt.QueryRow(ps[0].Cid).Scan(&cid)
	if err == nil {
		t.Error("Delete failed")
	}
}
<file_sep>package mobile

import (
	"encoding/base64"
	"encoding/json"
	"errors"

	"github.com/op/go-logging"

	tcore "github.com/textileio/textile-go/core"
	"github.com/textileio/textile-go/net"

	"gx/<KEY>libp2p-peer"
	libp2p "gx/<KEY>libp2p-crypto"

	"github.com/textileio/textile-go/central/models"
)

var log = logging.MustGetLogger("mobile")

// Message is a generic go -> bridge message structure
type Event struct {
	Name    string `json:"name"`
	Payload string `json:"payload"`
}

// newEvent transforms an event name and structured data in Event
func newEvent(name string, payload map[string]interface{}) *Event {
	event := &Event{Name: name}
	jsonb, err := json.Marshal(payload)
	if err != nil {
		log.Errorf("error creating event data json: %s", err)
	}
	event.Payload = string(jsonb)
	return event
}

// Messenger is used to inform the bridge layer of new data waiting to be queried
type Messenger interface {
	Notify(event *Event)
}

// Wrapper is the object exposed in the frameworks
type Wrapper struct {
	RepoPath  string
	messenger Messenger
}

// NodeConfig is used to configure the mobile node
type NodeConfig struct {
	RepoPath      string
	CentralApiURL string
	LogLevel      string
	LogFiles      bool
}

// NewNode is the mobile entry point for creating a node
// NOTE: logLevel is one of: CRITICAL ERROR WARNING NOTICE INFO DEBUG
func NewNode(config *NodeConfig, messenger Messenger) (*Wrapper, error) {
	var m Mobile
	return m.NewNode(config, messenger)
}

// Mobile is the name of the framework (must match package name)
type Mobile struct{}

// Create a gomobile compatible wrapper around TextileNode
func (m *Mobile) NewNode(config *NodeConfig, messenger Messenger) (*Wrapper, error) {
	ll, err := logging.LogLevel(config.LogLevel)
	if err != nil {
		ll = logging.INFO
	}
	cconfig := tcore.NodeConfig{
		RepoPath:      config.RepoPath,
		CentralApiURL: config.CentralApiURL,
		IsMobile:      true,
		LogLevel:      ll,
		LogFiles:      config.LogFiles,
	}
	node, err := tcore.NewNode(cconfig)
	if err != nil {
		return nil, err
	}
	tcore.Node = node

	return &Wrapper{RepoPath: config.RepoPath, messenger: messenger}, nil
}

// Start the mobile node
func (w *Wrapper) Start() error {
	if err := tcore.Node.Start(); err != nil {
		if err == tcore.ErrNodeRunning {
			return nil
		}
		return err
	}

	// join existing rooms
	for _, album := range tcore.Node.Datastore.Albums().GetAlbums("") {
		w.joinRoom(album.Id)
	}

	return nil
}

// Stop the mobile node
func (w *Wrapper) Stop() error {
	if err := tcore.Node.Stop(); err != nil && err != tcore.ErrNodeNotRunning {
		return err
	}
	return nil
}

// SignUpWithEmail creates an email based registration and calls core signup
func (w *Wrapper) SignUpWithEmail(username string, password string, email string, referral string) error {
	// build registration
	reg := &models.Registration{
		Username: username,
		Password: <PASSWORD>,
		Identity: &models.Identity{
			Type:  models.EmailAddress,
			Value: email,
		},
		Referral: referral,
	}

	// signup
	return tcore.Node.SignUp(reg)
}

// SignIn build credentials and calls core SignIn
func (w *Wrapper) SignIn(username string, password string) error {
	// build creds
	creds := &models.Credentials{
		Username: username,
		Password: <PASSWORD>,
	}

	// signin
	return tcore.Node.SignIn(creds)
}

// SignOut calls core SignOut
func (w *Wrapper) SignOut() error {
	return tcore.Node.SignOut()
}

// IsSignedIn calls core IsSignedIn
func (w *Wrapper) IsSignedIn() bool {
	si, _ := tcore.Node.IsSignedIn()
	return si
}

// Update thread allows the mobile client to choose the 'all' thread to subscribe
func (w *Wrapper) UpdateThread(mnemonic string, name string) error {
	if mnemonic == "" {
		return errors.New("mnemonic must not be empty")
	}
	if err := tcore.Node.TouchDB(); err != nil {
		return err
	}
	log.Debugf("deleting album if exists: %s", name)
	err := tcore.Node.Datastore.Albums().DeleteAlbumByName(name)
	if err != nil {
		log.Errorf("error deleting album %s: %s", name, err)
		return err
	}
	err = tcore.Node.CreateAlbum(mnemonic, name)
	if err != nil {
		log.Errorf("error creating album %s: %s", name, err)
		return err
	}
	return nil
}

// GetUsername calls core GetUsername
func (w *Wrapper) GetUsername() (string, error) {
	return tcore.Node.GetUsername()
}

// GetAccessToken calls core GetAccessToken
func (w *Wrapper) GetAccessToken() (string, error) {
	return tcore.Node.GetAccessToken()
}

// GetGatewayPassword returns the current cookie value expected by the gateway
func (w *Wrapper) GetGatewayPassword() string {
	return tcore.Node.GatewayPassword
}

// AddPhoto calls core AddPhoto
func (w *Wrapper) AddPhoto(path string, thumb string, thread string) (*net.MultipartRequest, error) {
	return tcore.Node.AddPhoto(path, thumb, thread, "")
}

// SharePhoto calls core SharePhoto
func (w *Wrapper) SharePhoto(hash string, thread string, caption string) (*net.MultipartRequest, error) {
	return tcore.Node.SharePhoto(hash, thread, caption)
}

// GetHashRequest calls core GetHashRequest
func (w *Wrapper) GetHashRequest(hash string) (string, error) {
	request := tcore.Node.GetHashRequest(hash)

	// gomobile does not allow slices. so, convert to json
	jsonb, err := json.Marshal(request)
	if err != nil {
		log.Errorf("error marshaling json: %s", err)
		return "", err
	}

	return string(jsonb), nil
}

// Get Photos returns core GetPhotos with json encoding
func (w *Wrapper) GetPhotos(offsetId string, limit int, thread string) (string, error) {
	if tcore.Node.Online() {
		go func() {
			th := tcore.Node.Datastore.Albums().GetAlbumByName(thread)
			if th != nil {
				tcore.Node.RepublishLatestUpdate(th)
			}
		}()
	}

	list := tcore.Node.GetPhotos(offsetId, limit, thread)
	if list == nil {
		list = &tcore.PhotoList{
			Hashes: make([]string, 0),
		}
	}

	// gomobile does not allow slices. so, convert to json
	jsonb, err := json.Marshal(list)
	if err != nil {
		log.Errorf("error marshaling json: %s", err)
		return "", err
	}

	return string(jsonb), nil
}

// GetFileBase64 call core GetFileBase64
func (w *Wrapper) GetFileBase64(path string) (string, error) {
	return tcore.Node.GetFileBase64(path)
}

// GetPeerID returns our peer id
func (w *Wrapper) GetPeerID() (string, error) {
	if !tcore.Node.Online() {
		return "", tcore.ErrNodeNotRunning
	}
	return tcore.Node.IpfsNode.Identity.Pretty(), nil
}

// PairDesktop publishes this nodes default album keys to a desktop node
// which is listening at it's own peer id
func (w *Wrapper) PairDesktop(pkb64 string) (string, error) {
	if !tcore.Node.Online() {
		return "", tcore.ErrNodeNotRunning
	}
	log.Info("pairing with desktop...")

	pkb, err := base64.StdEncoding.DecodeString(pkb64)
	if err != nil {
		log.Errorf("error decoding string: %s: %s", pkb64, err)
		return "", err
	}

	pk, err := libp2p.UnmarshalPublicKey(pkb)
	if err != nil {
		log.Errorf("error unmarshaling pub key: %s", err)
		return "", err
	}

	// the phrase will be used by the desktop client to create
	// the private key needed to decrypt photos
	// we invite the desktop to _read and write_ to our default album
	da := tcore.Node.Datastore.Albums().GetAlbumByName("default")
	if da == nil {
		err = errors.New("default album not found")
		log.Error(err.Error())
		return "", err
	}
	// encypt with the desktop's pub key
	cph, err := net.Encrypt(pk, []byte(da.Mnemonic))
	if err != nil {
		log.Errorf("encrypt failed: %s", err)
		return "", err
	}

	// get the topic to pair with from the pub key
	peerID, err := peer.IDFromPublicKey(pk)
	if err != nil {
		log.Errorf("id from public key failed: %s", err)
		return "", err
	}
	topic := peerID.Pretty()

	// finally, publish the encrypted phrase
	err = tcore.Node.Publish(topic, cph)
	if err != nil {
		log.Errorf("publish %s failed: %s", topic, err)
		return "", err
	}
	log.Infof("published key phrase to desktop: %s", topic)

	return topic, nil
}

// joinRoom and pass updates to messenger
func (w *Wrapper) joinRoom(id string) {
	datac := make(chan tcore.ThreadUpdate)
	go tcore.Node.JoinRoom(id, datac)
	go func() {
		for {
			select {
			case update, ok := <-datac:
				if !ok {
					return
				}
				w.messenger.Notify(newEvent("onThreadUpdate", map[string]interface{}{
					"cid":       update.Cid,
					"thread":    update.Thread,
					"thread_id": update.ThreadID,
				}))
			}
		}
	}()
}
<file_sep>package db

import (
	"database/sql"
	"path"
	"sync"
	"time"

	_ "github.com/mutecomm/go-sqlcipher"
	"github.com/op/go-logging"

	"github.com/textileio/textile-go/repo"
)

var log = logging.MustGetLogger("db")

const schemaVersion = "0"

type SQLiteDatastore struct {
	config   repo.Config
	settings repo.ConfigurationStore
	photos   repo.PhotoStore
	albums   repo.AlbumStore
	db       *sql.DB
	lock     *sync.Mutex
}

func Create(repoPath, password string) (*SQLiteDatastore, error) {
	var dbPath string
	dbPath = path.Join(repoPath, "datastore", "mainnet.db")
	conn, err := sql.Open("sqlite3", dbPath)
	if err != nil {
		return nil, err
	}
	if password != "" {
		p := "pragma key='" + password + "';"
		conn.Exec(p)
	}
	l := new(sync.Mutex)
	sqliteDB := &SQLiteDatastore{
		config: &ConfigDB{
			db:   conn,
			lock: l,
			path: dbPath,
		},
		settings: NewConfigurationStore(conn, l),
		photos:   NewPhotoStore(conn, l),
		albums:   NewAlbumStore(conn, l),
		db:       conn,
		lock:     l,
	}

	return sqliteDB, nil
}

func (d *SQLiteDatastore) Ping() error {
	return d.db.Ping()
}

func (d *SQLiteDatastore) Close() {
	d.db.Close()
}

func (d *SQLiteDatastore) Config() repo.Config {
	return d.config
}

func (d *SQLiteDatastore) Settings() repo.ConfigurationStore {
	return d.settings
}

func (d *SQLiteDatastore) Photos() repo.PhotoStore {
	return d.photos
}

func (d *SQLiteDatastore) Albums() repo.AlbumStore {
	return d.albums
}

func (d *SQLiteDatastore) Copy(dbPath string, password string) error {
	d.lock.Lock()
	defer d.lock.Unlock()
	var cp string
	stmt := "select name from sqlite_master where type='table'"
	rows, err := d.db.Query(stmt)
	if err != nil {
		log.Errorf("error in copy: %s", err)
		return err
	}
	var tables []string
	for rows.Next() {
		var name string
		if err := rows.Scan(&name); err != nil {
			return err
		}
		tables = append(tables, name)
	}
	if password == "" {
		cp = `attach database '` + dbPath + `' as plaintext key '';`
		for _, name := range tables {
			cp = cp + "insert into plaintext." + name + " select * from main." + name + ";"
		}
	} else {
		cp = `attach database '` + dbPath + `' as encrypted key '` + password + `';`
		for _, name := range tables {
			cp = cp + "insert into encrypted." + name + " select * from main." + name + ";"
		}
	}

	_, err = d.db.Exec(cp)
	if err != nil {
		return err
	}

	return nil
}

func (d *SQLiteDatastore) InitTables(password string) error {
	return initDatabaseTables(d.db, password)
}

func initDatabaseTables(db *sql.DB, password string) error {
	var sqlStmt string
	if password != "" {
		sqlStmt = "PRAGMA key = '" + password + "';"
	}
	sqlStmt += `
	PRAGMA user_version = 0;
	create table config (key text primary key not null, value blob);
	create table photos (cid text primary key not null, lastCid text, album text not null, name text not null, ext text not null, username text, peerId text, created integer, added integer not null, latitude real, longitude real, local integer not null, caption text);
    create index index_album_added on photos (album, added);
	create index index_album_local on photos (album, local);
	create index index_album_username on photos (album, username);
	create index index_album_peerid on photos (album, peerId);
    create table albums (id text primary key not null, key blob not null, mnemonic text not null, name text not null);
    create unique index index_name on albums (name);
	`
	_, err := db.Exec(sqlStmt)
	if err != nil {
		return err
	}
	return nil
}

type ConfigDB struct {
	db   *sql.DB
	lock *sync.Mutex
	path string
}

func (c *ConfigDB) Init(password string) error {
	c.lock.Lock()
	defer c.lock.Unlock()
	return initDatabaseTables(c.db, password)
}

func (c *ConfigDB) Configure(created time.Time) error {
	c.lock.Lock()
	defer c.lock.Unlock()
	tx, err := c.db.Begin()
	if err != nil {
		return err
	}
	stmt, err := tx.Prepare("insert or replace into config(key, value) values(?,?)")
	if err != nil {
		return err
	}
	defer stmt.Close()
	_, err = stmt.Exec("created", created.Format(time.RFC3339))
	if err != nil {
		tx.Rollback()
		return err
	}
	_, err = stmt.Exec("schema", schemaVersion)
	if err != nil {
		tx.Rollback()
		return err
	}
	tx.Commit()
	return nil
}

func (c *ConfigDB) SignIn(un string, at string, rt string) error {
	c.lock.Lock()
	defer c.lock.Unlock()
	tx, err := c.db.Begin()
	if err != nil {
		return err
	}
	stmt, err := tx.Prepare("insert or replace into config(key, value) values(?,?)")
	if err != nil {
		return err
	}
	defer stmt.Close()
	_, err = stmt.Exec("username", un)
	if err != nil {
		tx.Rollback()
		return err
	}
	_, err = stmt.Exec("access", at)
	if err != nil {
		tx.Rollback()
		return err
	}
	_, err = stmt.Exec("refresh", rt)
	if err != nil {
		tx.Rollback()
		return err
	}
	tx.Commit()
	return nil
}

func (c *ConfigDB) SignOut() error {
	c.lock.Lock()
	defer c.lock.Unlock()
	stmt, err := c.db.Prepare("delete from config where key=?")
	defer stmt.Close()
	_, err = stmt.Exec("username")
	if err != nil {
		return err
	}
	_, err = stmt.Exec("access")
	if err != nil {
		return err
	}
	_, err = stmt.Exec("refresh")
	if err != nil {
		return err
	}
	return nil
}

func (c *ConfigDB) GetUsername() (string, error) {
	c.lock.Lock()
	defer c.lock.Unlock()
	stmt, err := c.db.Prepare("select value from config where key=?")
	defer stmt.Close()
	var un string
	err = stmt.QueryRow("username").Scan(&un)
	if err != nil {
		return "", err
	}
	return un, nil
}

func (c *ConfigDB) GetTokens() (at string, rt string, err error) {
	c.lock.Lock()
	defer c.lock.Unlock()
	stmt, err := c.db.Prepare("select value from config where key=?")
	defer stmt.Close()
	err = stmt.QueryRow("access").Scan(&at)
	if err != nil {
		return "", "", err
	}
	err = stmt.QueryRow("refresh").Scan(&rt)
	if err != nil {
		return "", "", err
	}
	return at, rt, nil
}

func (c *ConfigDB) GetCreationDate() (time.Time, error) {
	c.lock.Lock()
	defer c.lock.Unlock()
	var t time.Time
	stmt, err := c.db.Prepare("select value from config where key=?")
	if err != nil {
		return t, err
	}
	defer stmt.Close()
	var created []byte
	err = stmt.QueryRow("created").Scan(&created)
	if err != nil {
		return t, err
	}
	return time.Parse(time.RFC3339, string(created))
}

func (c *ConfigDB) GetSchemaVersion() (string, error) {
	c.lock.Lock()
	defer c.lock.Unlock()
	stmt, err := c.db.Prepare("select value from config where key=?")
	defer stmt.Close()
	var sv string
	err = stmt.QueryRow("schema").Scan(&sv)
	if err != nil {
		return "", err
	}
	return sv, nil
}

func (c *ConfigDB) IsEncrypted() bool {
	c.lock.Lock()
	defer c.lock.Unlock()
	pwdCheck := "select count(*) from sqlite_master;"
	_, err := c.db.Exec(pwdCheck) // Fails if wrong password is entered
	if err != nil {
		return true
	}
	return false
}
<file_sep>package cmd

import (
	"errors"
	"fmt"
	"sort"

	"github.com/fatih/color"
	"github.com/textileio/textile-go/core"
	"gopkg.in/abiosoft/ishell.v2"
)

func ListAlbums(c *ishell.Context) {
	rooms := core.Node.IpfsNode.Floodsub.GetTopics()
	albums := core.Node.Datastore.Albums().GetAlbums("")

	if len(albums) == 0 {
		c.Println("no threads found")
	} else {
		c.Println(fmt.Sprintf("found %v threads", len(albums)))
	}

	blue := color.New(color.FgHiBlue).SprintFunc()
	for _, a := range albums {
		mem := "disabled"
		for _, r := range rooms {
			if r == a.Id {
				mem = "enabled"
			}
		}
		c.Println(blue(fmt.Sprintf("name: %s, id: %s, status: %s", a.Name, a.Id, mem)))
	}
}

func CreateAlbum(c *ishell.Context) {
	if len(c.Args) == 0 {
		c.Err(errors.New("missing thread name"))
		return
	}
	name := c.Args[0]

	c.Print("key pair mnemonic phrase (optional): ")
	mnemonic := c.ReadLine()

	if err := core.Node.CreateAlbum(mnemonic, name); err != nil {
		c.Err(err)
		return
	}

	a := core.Node.Datastore.Albums().GetAlbumByName(name)
	if a == nil {
		c.Err(errors.New(fmt.Sprintf("could not find thread: %s", name)))
		return
	}

	JoinRoom(c, a.Id)

	cyan := color.New(color.FgCyan).SprintFunc()
	c.Println(cyan(fmt.Sprintf("created thread #%s", name)))
}

func EnableAlbum(c *ishell.Context) {
	if len(c.Args) == 0 {
		c.Err(errors.New("missing thread name"))
		return
	}
	name := c.Args[0]

	a := core.Node.Datastore.Albums().GetAlbumByName(name)
	if a == nil {
		c.Err(errors.New(fmt.Sprintf("could not find thread: %s", name)))
		return
	}

	if core.Node.LeftRoomChs[a.Id] != nil {
		c.Printf("already enabled: %s\n", a.Id)
		return
	}

	JoinRoom(c, a.Id)

	c.Printf("ok, now enabled: %s\n", a.Id)
}

func DisableAlbum(c *ishell.Context) {
	if len(c.Args) == 0 {
		c.Err(errors.New("missing thread name"))
		return
	}
	name := c.Args[0]

	a := core.Node.Datastore.Albums().GetAlbumByName(name)
	if a == nil {
		c.Err(errors.New(fmt.Sprintf("could not find thread: %s", name)))
		return
	}

	if core.Node.LeftRoomChs[a.Id] == nil {
		c.Printf("already disabled: %s\n", a.Id)
		return
	}

	core.Node.LeaveRoom(a.Id)
	<-core.Node.LeftRoomChs[a.Id]

	c.Printf("ok, now disabled: %s\n", a.Id)
}

func AlbumMnemonic(c *ishell.Context) {
	if len(c.Args) == 0 {
		c.Err(errors.New("missing thread name"))
		return
	}
	name := c.Args[0]

	a := core.Node.Datastore.Albums().GetAlbumByName(name)
	if a == nil {
		c.Err(errors.New(fmt.Sprintf("could not find thread: %s", name)))
		return
	}

	green := color.New(color.FgGreen).SprintFunc()
	c.Println(green(a.Mnemonic))
}

func RepublishAlbum(c *ishell.Context) {
	if len(c.Args) == 0 {
		c.Err(errors.New("missing thread name"))
		return
	}
	name := c.Args[0]

	a := core.Node.Datastore.Albums().GetAlbumByName(name)
	if a == nil {
		c.Err(errors.New(fmt.Sprintf("could not find thread: %s", name)))
		return
	}

	recent := core.Node.Datastore.Photos().GetPhotos("", 1, "album='"+a.Id+"' and local=1")
	if len(recent) == 0 {
		c.Println(fmt.Sprintf("no updates to publish in: %s", name))
		return
	}
	latest := recent[0].Cid

	// publish it
	if err := core.Node.IpfsNode.Floodsub.Publish(a.Id, []byte(latest)); err != nil {
		c.Err(fmt.Errorf("error re-publishing update: %s", err))
		return
	}

	blue := color.New(color.FgHiBlue).SprintFunc()
	c.Println(blue(fmt.Sprintf("published %s to %s", latest, a.Id)))
}

func ListAlbumPeers(c *ishell.Context) {
	if len(c.Args) == 0 {
		c.Err(errors.New("missing thread name"))
		return
	}
	name := c.Args[0]

	a := core.Node.Datastore.Albums().GetAlbumByName(name)
	if a == nil {
		c.Err(errors.New(fmt.Sprintf("could not find thread: %s", name)))
		return
	}

	peers := core.Node.IpfsNode.Floodsub.ListPeers(a.Id)
	var list []string
	for _, peer := range peers {
		list = append(list, peer.Pretty())
	}
	sort.Strings(list)

	if len(list) == 0 {
		c.Println(fmt.Sprintf("no peers found in: %s", name))
	} else {
		c.Println(fmt.Sprintf("found %v peers in: %s", len(list), name))
	}

	green := color.New(color.FgHiGreen).SprintFunc()
	for _, peer := range list {
		c.Println(green(peer))
	}
}

func JoinRoom(shell ishell.Actions, id string) {
	cyan := color.New(color.FgCyan).SprintFunc()
	datac := make(chan core.ThreadUpdate)
	go core.Node.JoinRoom(id, datac)
	go func() {
		for {
			select {
			case update, ok := <-datac:
				if !ok {
					return
				}
				msg := fmt.Sprintf("\nnew photo %s in %s thread", update.Cid, update.Thread)
				shell.ShowPrompt(false)
				shell.Printf(cyan(msg))
				shell.ShowPrompt(true)
			}
		}
	}()
}
<file_sep>package db

import (
	"database/sql"
	"sync"
	"testing"

	"github.com/textileio/textile-go/repo"

	libp2p "gx/ipfs/QmaPbCnUMBohSGo3KnxEa2bHqyJVVeEEcwtqJAYxerieBo/go-libp2p-crypto"
)

var aldb repo.AlbumStore

func init() {
	setupAlbumDB()
}

func setupAlbumDB() {
	conn, _ := sql.Open("sqlite3", ":memory:")
	initDatabaseTables(conn, "")
	aldb = NewAlbumStore(conn, new(sync.Mutex))
}

func TestAlbumDB_Put(t *testing.T) {
	priv, _, err := libp2p.GenerateKeyPair(libp2p.Ed25519, 0)
	if err != nil {
		t.Error(err)
	}
	err = aldb.Put(&repo.PhotoAlbum{
		Id:       "Qmabc123",
		Key:      priv,
		Mnemonic: "",
		Name:     "boom",
	})
	if err != nil {
		t.Error(err)
	}
	stmt, err := aldb.PrepareQuery("select id from albums where id=?")
	defer stmt.Close()
	var id string
	err = stmt.QueryRow("Qmabc123").Scan(&id)
	if err != nil {
		t.Error(err)
	}
	if id != "Qmabc123" {
		t.Errorf(`expected "Qmabc123" got %s`, id)
	}
}

func TestAlbumDB_GetAlbum(t *testing.T) {
	setupAlbumDB()
	priv, _, err := libp2p.GenerateKeyPair(libp2p.Ed25519, 0)
	if err != nil {
		t.Error(err)
	}
	err = aldb.Put(&repo.PhotoAlbum{
		Id:       "Qmabc",
		Key:      priv,
		Mnemonic: "",
		Name:     "boom",
	})
	if err != nil {
		t.Error(err)
	}
	p := aldb.GetAlbum("Qmabc")
	if p == nil {
		t.Error("could not get album")
	}
}

func TestAlbumDB_GetAlbumByName(t *testing.T) {
	setupAlbumDB()
	priv, _, err := libp2p.GenerateKeyPair(libp2p.Ed25519, 0)
	if err != nil {
		t.Error(err)
	}
	err = aldb.Put(&repo.PhotoAlbum{
		Id:       "Qmabc",
		Key:      priv,
		Mnemonic: "",
		Name:     "boom",
	})
	if err != nil {
		t.Error(err)
	}
	err = aldb.Put(&repo.PhotoAlbum{
		Id:       "Qmabc2",
		Key:      priv,
		Mnemonic: "",
		Name:     "boom",
	})
	if err == nil {
		t.Error("unique constraint on name failed")
	}
	p := aldb.GetAlbumByName("boom")
	if p == nil {
		t.Error("could not get album")
	}
}

func TestAlbumDB_GetAlbums(t *testing.T) {
	setupAlbumDB()
	priv, _, err := libp2p.GenerateKeyPair(libp2p.Ed25519, 0)
	if err != nil {
		t.Error(err)
	}
	err = aldb.Put(&repo.PhotoAlbum{
		Id:       "Qm123",
		Key:      priv,
		Mnemonic: "",
		Name:     "boom",
	})
	if err != nil {
		t.Error(err)
	}
	priv, _, err = libp2p.GenerateKeyPair(libp2p.Ed25519, 0)
	if err != nil {
		t.Error(err)
	}
	err = aldb.Put(&repo.PhotoAlbum{
		Id:       "Qm456",
		Key:      priv,
		Mnemonic: "",
		Name:     "boom2",
	})
	if err != nil {
		t.Error(err)
	}
	as := aldb.GetAlbums("")
	if len(as) != 2 {
		t.Error("returned incorrect number of albums")
		return
	}

	filtered := aldb.GetAlbums("name='boom2'")
	if len(filtered) != 1 {
		t.Error("returned incorrect number of albums")
		return
	}
}

func TestAlbumDB_DeleteAlbum(t *testing.T) {
	setupAlbumDB()
	priv, _, err := libp2p.GenerateKeyPair(libp2p.Ed25519, 0)
	if err != nil {
		t.Error(err)
	}
	err = aldb.Put(&repo.PhotoAlbum{
		Id:       "Qm789",
		Key:      priv,
		Mnemonic: "",
		Name:     "boom",
	})
	if err != nil {
		t.Error(err)
	}
	as := aldb.GetAlbums("")
	if len(as) == 0 {
		t.Error("returned incorrect number of albums")
		return
	}
	err = aldb.DeleteAlbum(as[0].Id)
	if err != nil {
		t.Error(err)
	}
	stmt, err := phdb.PrepareQuery("select id from albums where id=?")
	defer stmt.Close()
	var id string
	err = stmt.QueryRow(as[0].Id).Scan(&id)
	if err == nil {
		t.Error("Delete failed")
	}
}
<file_sep>package core

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"io/ioutil"
	"net/http"
	"os"
	"path"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/op/go-logging"
	"github.com/segmentio/ksuid"
	"github.com/tyler-smith/go-bip39"
	"gopkg.in/natefinch/lumberjack.v2"

	cmodels "github.com/textileio/textile-go/central/models"
	"github.com/textileio/textile-go/core/central"
	"github.com/textileio/textile-go/net"
	trepo "github.com/textileio/textile-go/repo"
	tconfig "github.com/textileio/textile-go/repo/config"
	"github.com/textileio/textile-go/repo/db"
	"github.com/textileio/textile-go/repo/photos"
	"github.com/textileio/textile-go/util"

	utilmain "gx/ipfs/<KEY>/go-ipfs/cmd/ipfs/util"
	oldcmds "gx/ipfs/<KEY>/go-ipfs/commands"
	"gx/ipfs/<KEY>/go-ipfs/core"
	"gx/ipfs/<KEY>/go-ipfs/core/coreapi"
	"gx/ipfs/<KEY>/go-ipfs/core/coreapi/interface"
	"gx/<KEY>go-ipfs/core/coreapi/interface/options"
	"gx/<KEY>go-ipfs/repo/config"
	"gx/<KEY>/repo/fsrepo"

	"gx/<KEY>libp2p-floodsub"
	"gx/<KEY>libp2p-swarm"
	pstore "gx/<KEY>go-libp2p-peerstore"
	"gx/<KEY>go-libp2p-peer"
	libp2p "gx/<KEY>go-libp2p-crypto"
	"gx/<KEY>libp2p-kad-dht"
)

const (
	Version = "0.0.1"
)

var fileLogFormat = logging.MustStringFormatter(
	`%{time:15:04:05.000} [%{shortfunc}] [%{level}] %{message}`,
)
var log = logging.MustGetLogger("core")

const roomRepublishInterval = time.Minute * 1
const pingTimeout = time.Second * 10
const pinTimeout = time.Minute * 1
const catTimeout = time.Second * 30

// Node is the single TextileNode instance
var Node *TextileNode

// ErrNodeRunning is an error for when node start is called on a running node
var ErrNodeRunning = errors.New("node is already running")

// ErrNodeNotRunning is an error for  when node stop is called on a nil node
var ErrNodeNotRunning = errors.New("node is not running")

// TextileNode is the main node interface for textile functionality
type TextileNode struct {
	// Context for issuing IPFS commands
	Context oldcmds.Context

	// IPFS node object
	IpfsNode *core.IpfsNode

	// The path to the openbazaar repo in the file system
	RepoPath string

	// Database for storing node specific data
	Datastore trepo.Datastore

	// Function to call for shutdown
	Cancel context.CancelFunc

	// The local decrypting gateway server
	GatewayProxy *http.Server

	// Map of hash passwords for the decrypting gateway server
	HashPasses map[string]string

	// The local password used to authenticate http gateway requests (username is TextileNode)
	GatewayPassword string

	// Signals for when we've left rooms
	LeftRoomChs map[string]chan struct{}

	// Signals for leaving rooms
	leaveRoomChs map[string]chan struct{}

	// IPFS configuration used to instantiate new ipfs nodes
	ipfsConfig *core.BuildCfg

	// Whether or not we're running on a mobile device
	isMobile bool

	// API URL of the central backup / recovery / pinning user service
	centralUserAPI string

	// Whether or not we've just inited, and never run, a "fresh" node :)
	fresh bool

	// Whether or no we're online
	online bool

	// Mutex for controlling lifecycle
	mux sync.Mutex

	// Captures stdout from ipfs packages
	stdOutLogger *util.StdOutLogger
}

// PhotoList contains a list of photo hashes
type PhotoList struct {
	Hashes []string `json:"hashes"`
}

// HashRequest represents a single-use gateway token
type HashRequest struct {
	Token    string `json:"token"`
	Protocol string `json:"protocol"`
	Host     string `json:"host"`
}

// ThreadUpdate is used to notify listeners about updates in a thread
type ThreadUpdate struct {
	Cid      string `json:"cid"`
	Thread   string `json:"thread"`
	ThreadID string `json:"thread_id"`
}

// NodeConfig is used to configure the node
type NodeConfig struct {
	RepoPath      string
	CentralApiURL string
	IsMobile      bool
	IsServer      bool
	LogLevel      logging.Level
	LogFiles      bool
	SwarmPort     string
}

// NewNode creates a new TextileNode
func NewNode(config NodeConfig) (*TextileNode, error) {
	// TODO: shouldn't need to manually remove these
	repoLockFile := filepath.Join(config.RepoPath, fsrepo.LockFile)
	os.Remove(repoLockFile)
	dsLockFile := filepath.Join(config.RepoPath, "datastore", "LOCK")
	os.Remove(dsLockFile)

	// log handling
	var backendFile *logging.LogBackend
	if config.LogFiles {
		w := &lumberjack.Logger{
			Filename:   path.Join(config.RepoPath, "logs", "textile.log"),
			MaxSize:    10, // megabytes
			MaxBackups: 3,
			MaxAge:     30, // days
		}
		backendFile = logging.NewLogBackend(w, "", 0)
	} else {
		backendFile = logging.NewLogBackend(os.Stdout, "", 0)
	}
	backendFileFormatter := logging.NewBackendFormatter(backendFile, fileLogFormat)
	logging.SetBackend(backendFileFormatter)
	logging.SetLevel(config.LogLevel, "")

	// capture stdout from ipfs packages
	stdOutLogger, err := util.NewStdOutLogger(logging.MustGetLogger("ipfs"))
	if err != nil {
		log.Errorf("error creating stdout logger: %s", err)
		return nil, err
	}

	// get database handle
	sqliteDB, err := db.Create(config.RepoPath, "")
	if err != nil {
		return nil, err
	}

	// we may be running in an uninitialized state.
	err = trepo.DoInit(config.RepoPath, config.IsMobile, sqliteDB.Config().Init, sqliteDB.Config().Configure)
	if err != nil && err != trepo.ErrRepoExists {
		return nil, err
	}

	// acquire the repo lock _before_ constructing a node. we need to make
	// sure we are permitted to access the resources (datastore, etc.)
	repo, err := fsrepo.Open(config.RepoPath)
	if err != nil {
		log.Errorf("error opening repo: %s", err)
		return nil, err
	}

	// determine the best routing
	var routingOption core.RoutingOption
	if config.IsMobile {
		routingOption = core.DHTClientOption
	} else {
		routingOption = core.DHTOption
	}

	// assemble node config
	ncfg := &core.BuildCfg{
		Repo:      repo,
		Permanent: true, // temporary way to signify that node is permanent
		Online:    true,
		ExtraOpts: map[string]bool{
			"pubsub": true,
			"ipnsps": true,
			"mplex":  true,
		},
		Routing: routingOption,
	}

	// setup gateway
	gwAddr, err := repo.GetConfigKey("Addresses.Gateway")
	if err != nil {
		log.Errorf("error getting ipfs config: %s", err)
		return nil, err
	}
	gatewayProxy := &http.Server{
		Addr: gwAddr.(string),
	}

	// if a specific swarm port was selected, set it in the config
	if config.SwarmPort != "" {
		log.Infof("using specified swarm port: %s", config.SwarmPort)
		if err := tconfig.Update(repo, "Addresses.Swarm", []string{
			fmt.Sprintf("/ip4/0.0.0.0/tcp/%s", config.SwarmPort),
			fmt.Sprintf("/ip6/::/tcp/%s", config.SwarmPort),
		}); err != nil {
			return nil, err
		}
	}

	// if this is a server node, apply the ipfs server profile
	if config.IsServer {
		if err := tconfig.Update(repo, "Addresses.NoAnnounce", tconfig.DefaultServerFilters); err != nil {
			return nil, err
		}
		if err := tconfig.Update(repo, "Swarm.AddrFilters", tconfig.DefaultServerFilters); err != nil {
			return nil, err
		}
		if err := tconfig.Update(repo, "Swarm.EnableRelayHop", true); err != nil {
			return nil, err
		}
		if err := tconfig.Update(repo, "Discovery.MDNS.Enabled", false); err != nil {
			return nil, err
		}
		log.Info("applied server profile")
	}

	// clean central api url
	if len(config.CentralApiURL) > 0 {
		ca := config.CentralApiURL
		if ca[len(ca)-1:] == "/" {
			ca = ca[0 : len(ca)-1]
		}
		config.CentralApiURL = ca
	}

	// finally, construct our node
	node := &TextileNode{
		RepoPath:        config.RepoPath,
		Datastore:       sqliteDB,
		GatewayProxy:    gatewayProxy,
		GatewayPassword: <PASSWORD>.New().String(),
		HashPasses:      make(map[string]string),
		LeftRoomChs:     make(map[string]chan struct{}),
		leaveRoomChs:    make(map[string]chan struct{}),
		ipfsConfig:      ncfg,
		isMobile:        config.IsMobile,
		centralUserAPI:  fmt.Sprintf("%s/api/v1/users", config.CentralApiURL),
		fresh:           true,
		stdOutLogger:    stdOutLogger,
	}

	// create default album
	da := node.Datastore.Albums().GetAlbumByName("default")
	if da == nil {
		err = node.CreateAlbum("", "default")
		if err != nil {
			log.Errorf("error creating default album: %s", err)
			return nil, err
		}
	}

	return node, nil
}

// Start the node
func (t *TextileNode) Start() error {
	t.mux.Lock()
	defer t.mux.Unlock()
	t.fresh = false
	if t.Online() {
		return ErrNodeRunning
	}
	log.Info("starting node...")

	// start capturing
	//t.stdOutLogger.Start()

	// raise file descriptor limit
	if err := utilmain.ManageFdLimit(); err != nil {
		log.Errorf("setting file descriptor limit: %s", err)
	}

	// check db
	if err := t.touchDB(); err != nil {
		return err
	}

	// check repo
	if t.ipfsConfig.Repo == nil {
		log.Debug("re-opening repo...")
		repo, err := fsrepo.Open(t.RepoPath)
		if err != nil {
			log.Errorf("error re-opening repo: %s", err)
			return err
		}
		t.ipfsConfig.Repo = repo
	}

	// start the ipfs node
	log.Debug("creating an ipfs node...")
	cctx, cancel := context.WithCancel(context.Background())
	t.Cancel = cancel
	nd, err := core.NewNode(cctx, t.ipfsConfig)
	if err != nil {
		log.Errorf("error creating ipfs node: %s", err)
		return err
	}
	nd.SetLocal(false)

	// print swarm addresses
	if err = printSwarmAddrs(nd); err != nil {
		log.Errorf("failed to read listening addresses: %s", err)
	}

	// build the node
	ctx := oldcmds.Context{}
	ctx.Online = true
	ctx.ConfigRoot = t.RepoPath
	ctx.LoadConfig = func(path string) (*config.Config, error) {
		return fsrepo.ConfigAt(t.RepoPath)
	}
	ctx.ConstructNode = func() (*core.IpfsNode, error) {
		return nd, nil
	}
	t.Context = ctx
	t.IpfsNode = nd

	// construct decrypting http gateway
	var gwpErrc <-chan error
	gwpErrc, err = t.startGateway()
	if err != nil {
		log.Errorf("error starting decrypting gateway: %s", err)
		return err
	}
	go func() {
		for {
			select {
			case err, ok := <-gwpErrc:
				if err != nil && err.Error() != "http: Server closed" {
					log.Errorf("gateway error: %s", err)
				}
				if !ok {
					log.Info("decrypting gateway was shutdown")
					return
				}
			}
		}
	}()

	// wait for dht to bootstrap
	<-dht.DefaultBootstrapConfig.DoneChan

	if !t.isMobile {
		// every min, send out latest room updates
		go t.startRepublishing()
	}

	// dunzo
	if t.isMobile {
		log.Info("mobile node is ready")
	} else {
		log.Info("desktop node is ready")
	}
	t.online = true
	return nil
}

// Stop the node
func (t *TextileNode) Stop() error {
	t.mux.Lock()
	defer t.mux.Unlock()
	if !t.Online() {
		return ErrNodeNotRunning
	}
	log.Info("stopping node...")

	// shutdown the gateway
	cgCtx, cancelCGW := context.WithCancel(context.Background())
	if err := t.GatewayProxy.Shutdown(cgCtx); err != nil {
		log.Errorf("error shutting down gateway: %s", err)
		return err
	}

	// close ipfs node command context
	t.Context.Close()

	// cancel textile node background context
	t.Cancel()

	// close the ipfs node
	if err := t.IpfsNode.Close(); err != nil {
		log.Errorf("error closing ipfs node: %s", err)
		return err
	}

	// force the gateway closed if it's not already closed
	cancelCGW()

	// close db connection
	t.Datastore.Close()
	dsLockFile := filepath.Join(t.RepoPath, "datastore", "LOCK")
	if err := os.Remove(dsLockFile); err != nil {
		log.Errorf("error removing ds lock: %s", err)
	}

	// clean up node
	t.IpfsNode = nil
	t.ipfsConfig.Repo = nil

	// stop capturing
	//t.stdOutLogger.Stop()

	t.online = false
	return nil
}

func (t *TextileNode) Online() bool {
	return t.fresh || t.online
}

// StartGarbageCollection starts auto garbage cleanup
// TODO: verify this is finding the correct repo, might be using IPFS_PATH
func (t *TextileNode) StartGarbageCollection() (<-chan error, error) {
	if !t.Online() {
		return nil, ErrNodeNotRunning
	}
	if t.isMobile {
		return nil, errors.New("services not available on mobile")
	}

	// repo blockstore GC
	var gcErrc <-chan error
	var err error
	gcErrc, err = runGC(t.IpfsNode.Context(), t.IpfsNode)
	if err != nil {
		log.Errorf("error starting gc: %s", err)
		return nil, err
	}

	return gcErrc, nil
}

// SignUp requests a new username and token from the central api and saves them locally
func (t *TextileNode) SignUp(reg *cmodels.Registration) error {
	// check db
	if err := t.touchDB(); err != nil {
		return err
	}
	log.Debugf("signup: %s %s %s %s %s", reg.Username, "xxxxxx", reg.Identity.Type, reg.Identity.Value, reg.Referral)

	// remote signup
	res, err := central.SignUp(reg, t.centralUserAPI)
	if err != nil {
		log.Errorf("signup error: %s", err)
		return err
	}
	if res.Error != nil {
		log.Errorf("signup error from central: %s", *res.Error)
		return errors.New(*res.Error)
	}

	// local signin
	if err := t.Datastore.Config().SignIn(reg.Username, res.Session.AccessToken, res.Session.RefreshToken); err != nil {
		log.Errorf("local signin error: %s", err)
		return err
	}
	return nil
}

// SignIn requests a token with a username from the central api and saves them locally
func (t *TextileNode) SignIn(creds *cmodels.Credentials) error {
	// check db
	if err := t.touchDB(); err != nil {
		return err
	}
	log.Debugf("signin: %s %s", creds.Username, "xxxxxx")

	// remote signin
	res, err := central.SignIn(creds, t.centralUserAPI)
	if err != nil {
		log.Errorf("signin error: %s", err)
		return err
	}
	if res.Error != nil {
		log.Errorf("signin error from central: %s", *res.Error)
		return errors.New(*res.Error)
	}

	// local signin
	if err := t.Datastore.Config().SignIn(creds.Username, res.Session.AccessToken, res.Session.RefreshToken); err != nil {
		log.Errorf("local signin error: %s", err)
		return err
	}
	return nil
}

// SignOut deletes the locally saved user info (username and tokens)
func (t *TextileNode) SignOut() error {
	// check db
	if err := t.touchDB(); err != nil {
		return err
	}
	log.Debug("signing out...")

	// remote is stateless, so we just ditch the local token
	if err := t.Datastore.Config().SignOut(); err != nil {
		log.Errorf("local signout error: %s", err)
		return err
	}
	return nil
}

// IsSignedIn returns whether or not a user is signed in
func (t *TextileNode) IsSignedIn() (bool, error) {
	// check db
	if err := t.touchDB(); err != nil {
		return false, err
	}
	_, err := t.Datastore.Config().GetUsername()
	return err == nil, nil
}

// GetUsername returns the current user's username
func (t *TextileNode) GetUsername() (string, error) {
	// check db
	if err := t.touchDB(); err != nil {
		return "", err
	}
	un, err := t.Datastore.Config().GetUsername()
	if err != nil {
		return "", err
	}
	return un, nil
}

// GetAccessToken returns the current access_token (jwt) for central
func (t *TextileNode) GetAccessToken() (string, error) {
	// check db
	if err := t.touchDB(); err != nil {
		return "", err
	}
	at, _, err := t.Datastore.Config().GetTokens()
	if err != nil {
		return "", err
	}
	return at, nil
}

// JoinRoom with a given id
func (t *TextileNode) JoinRoom(id string, datac chan ThreadUpdate) {
	if !t.Online() {
		return
	}

	// create the subscription
	sub, err := t.IpfsNode.Floodsub.Subscribe(id)
	if err != nil {
		log.Errorf("error creating subscription: %s", err)
		return
	}
	log.Infof("joined room: %s\n", id)

	t.leaveRoomChs[id] = make(chan struct{})
	t.LeftRoomChs[id] = make(chan struct{})

	api := coreapi.NewCoreAPI(t.IpfsNode)
	ctx, cancel := context.WithCancel(context.Background())
	leave := func() {
		cancel()
		close(t.LeftRoomChs[id])
		delete(t.LeftRoomChs, id)
		delete(t.leaveRoomChs, id)
		log.Infof("left room: %s\n", sub.Topic())
	}

	defer func() {
		defer func() {
			if r := recover(); r != nil {
				log.Errorf("room data channel already closed")
			}
		}()
		close(datac)
	}()
	go func() {
		for {
			// unload new message
			msg, err := sub.Next(ctx)
			if err == io.EOF || err == context.Canceled {
				log.Debugf("room subscription ended: %s", err)
				return
			} else if err != nil {
				log.Debugf(err.Error())
				return
			}

			// handle the update
			go func(msg *floodsub.Message) {
				if err = t.handleRoomUpdate(msg, id, api, datac); err != nil {
					log.Errorf("error handling room update: %s", err)
				}
			}(msg)
		}
	}()

	// block so we can shutdown with the leave room signal
	for {
		select {
		case <-t.leaveRoomChs[id]:
			leave()
			return
		case <-t.IpfsNode.Context().Done():
			leave()
			return
		}
	}
}

// LeaveRoom with a given id
func (t *TextileNode) LeaveRoom(id string) {
	if !t.Online() {
		return
	}

	if t.leaveRoomChs[id] == nil {
		return
	}
	close(t.leaveRoomChs[id])
}

// WaitForRoom to join
func (t *TextileNode) WaitForRoom() {
	if !t.Online() {
		return
	}

	// we're in a lonesome state here, we can just sub to our own
	// peer id and hope somebody sends us a priv key to join a room with
	rid := t.IpfsNode.Identity.Pretty()
	sub, err := t.IpfsNode.Floodsub.Subscribe(rid)
	if err != nil {
		log.Errorf("error creating subscription: %s", err)
		return
	}
	log.Infof("waiting for room at own peer id: %s\n", rid)

	ctx, cancel := context.WithCancel(context.Background())
	cancelCh := make(chan struct{})
	go func() {
		for {
			msg, err := sub.Next(ctx)
			if err == io.EOF || err == context.Canceled {
				log.Debugf("wait subscription ended: %s", err)
				return
			} else if err != nil {
				log.Debugf(err.Error())
				return
			}
			from := msg.GetFrom().Pretty()
			log.Infof("got pairing request from: %s\n", from)

			// get private peer key and decrypt the phrase
			sk, err := t.UnmarshalPrivatePeerKey()
			if err != nil {
				log.Errorf("error unmarshaling priv peer key: %s", err)
				return
			}
			p, err := net.Decrypt(sk, msg.GetData())
			if err != nil {
				log.Errorf("error decrypting msg data: %s", err)
				return
			}
			ps := string(p)
			log.Debugf("decrypted mnemonic phrase as: %s\n", ps)

			// create a new album for the room
			// TODO: let user name this or take phone's name, e.g., bob's iphone
			// TODO: or auto name it, cause this means only one pairing can happen
			t.CreateAlbum(ps, "mobile")

			// we're done
			close(cancelCh)
		}
	}()

	for {
		select {
		case <-cancelCh:
			cancel()
			return
		case <-t.IpfsNode.Context().Done():
			cancel()
			return
		}
	}
}

// CreateAlbum creates an album with a given name and mnemonic words
func (t *TextileNode) CreateAlbum(mnemonic string, name string) error {
	// check db
	if err := t.touchDB(); err != nil {
		return err
	}
	log.Debugf("creating a new album: %s", name)

	// use phrase if provided
	if mnemonic == "" {
		var err error
		mnemonic, err = createMnemonic(bip39.NewEntropy, bip39.NewMnemonic)
		if err != nil {
			log.Errorf("error creating mnemonic: %s", err)
			return err
		}
		log.Debugf("generating %v-bit Ed25519 keypair for: %s", trepo.NBitsForKeypair, name)
	} else {
		log.Debugf("regenerating Ed25519 keypair from mnemonic phrase for: %s", name)
	}

	// create the bip39 seed from the phrase
	seed := bip39.NewSeed(mnemonic, "")
	kb, err := identityKeyFromSeed(seed, trepo.NBitsForKeypair)
	if err != nil {
		log.Errorf("error creating identity from seed: %s", err)
		return err
	}

	// convert to a libp2p crypto private key
	sk, err := libp2p.UnmarshalPrivateKey(kb)
	if err != nil {
		log.Errorf("error unmarshaling private key: %s", err)
		return err
	}

	// we need the resultant peer id to use as the album's id
	id, err := peer.IDFromPrivateKey(sk)
	if err != nil {
		log.Errorf("error getting id from priv key: %s", err)
		return err
	}

	// finally, create the album
	album := &trepo.PhotoAlbum{
		Id:       id.Pretty(),
		Key:      sk,
		Mnemonic: mnemonic,
		Name:     name,
	}
	return t.Datastore.Albums().Put(album)
}

// AddPhoto adds a photo and its thumbnail to an album
// TODO: Make this available offline
func (t *TextileNode) AddPhoto(path string, thumb string, album string, caption string) (*net.MultipartRequest, error) {
	if !t.Online() {
		return nil, ErrNodeNotRunning
	}
	log.Debugf("adding photo %s to %s", path, album)

	// read file from disk
	p, err := os.Open(path)
	if err != nil {
		log.Errorf("error opening photo: %s", err)
		return nil, err
	}
	defer p.Close()

	th, err := os.Open(thumb)
	if err != nil {
		log.Errorf("error opening thumb: %s", err)
		return nil, err
	}
	defer th.Close()

	// get album private key
	a := t.Datastore.Albums().GetAlbumByName(album)
	if a == nil {
		err = errors.New(fmt.Sprintf("could not find album: %s", album))
		log.Error(err.Error())
		return nil, err
	}

	// get last photo update which has local true
	var lc string
	recent := t.Datastore.Photos().GetPhotos("", 1, "album='"+a.Id+"' and local=1")
	if len(recent) > 0 {
		lc = recent[0].Cid
		log.Debugf("found last hash: %s", lc)
	}

	// get username
	un, err := t.Datastore.Config().GetUsername()
	if err != nil {
		log.Errorf("username not found (not signed in)")
		un = ""
	}

	// add it
	// TODO: clean up this nasty method signature
	mr, md, err := photos.Add(t.IpfsNode, a.Key.GetPublic(), p, th, lc, un, caption)
	if err != nil {
		log.Errorf("error adding photo: %s", err)
		return nil, err
	}

	// index
	log.Debugf("indexing %s...", mr.Boundary)
	set := &trepo.PhotoSet{
		Cid:      mr.Boundary,
		LastCid:  lc,
		AlbumID:  a.Id,
		MetaData: *md,
		Caption:  caption,
		IsLocal:  true,
	}
	err = t.Datastore.Photos().Put(set)
	if err != nil {
		log.Errorf("error indexing photo: %s", err)
		return nil, err
	}

	// publish
	go func() {
		err = t.Publish(a.Id, []byte(mr.Boundary))
		if err != nil {
			log.Errorf("error publishing photo update: %s", err)
			return
		}
		log.Debugf("published update to %s", a.Id)
	}()

	return mr, nil
}

// SharePhoto re-encrypts a photo from an existing album and shares it into a different album
// TODO: Make this available offline
func (t *TextileNode) SharePhoto(hash string, album string, caption string) (*net.MultipartRequest, error) {
	if !t.Online() {
		return nil, ErrNodeNotRunning
	}
	log.Debugf("sharing photo %s to %s...", hash, album)

	// get the photo
	set, a, err := t.LoadPhotoAndAlbum(hash)
	if err != nil {
		log.Error(err.Error())
		return nil, err
	}

	// get dest album
	na := t.Datastore.Albums().GetAlbumByName(album)
	if na == nil {
		return nil, errors.New(fmt.Sprintf("could not find album: %s", album))
	}

	// check if album is diff
	if a.Id == na.Id {
		return nil, errors.New(fmt.Sprintf("photo already in album: %s", album))
	}

	// get photo data
	pb, err := t.GetFile(fmt.Sprintf("%s/photo", hash), a)
	if err != nil {
		return nil, err
	}
	tb, err := t.GetFile(fmt.Sprintf("%s/thumb", hash), a)
	if err != nil {
		return nil, err
	}

	// temp write to disk
	ppath := filepath.Join(t.RepoPath, "tmp", set.MetaData.Name+set.MetaData.Ext)
	tpath := filepath.Join(t.RepoPath, "tmp", "thumb_"+set.MetaData.Name+set.MetaData.Ext)
	err = ioutil.WriteFile(ppath, pb, 0644)
	if err != nil {
		return nil, err
	}
	defer func() {
		err := os.Remove(ppath)
		if err != nil {
			log.Errorf("error cleaning up shared photo path: %s", ppath)
		}
	}()
	err = ioutil.WriteFile(tpath, tb, 0644)
	if err != nil {
		return nil, err
	}
	defer func() {
		err = os.Remove(tpath)
		if err != nil {
			log.Errorf("error cleaning up shared thumb path: %s", tpath)
		}
	}()

	return t.AddPhoto(ppath, tpath, album, caption)
}

// GetHashRequest returns a single-use token for requesting content via the gateway
func (t *TextileNode) GetHashRequest(hash string) HashRequest {
	token := ksuid.New().String()
	t.HashPasses[hash] = token
	return HashRequest{
		Token:    token,
		Protocol: "http",
		Host:     t.GatewayProxy.Addr,
	}
}

// GetPhotos paginates photos from the datastore
func (t *TextileNode) GetPhotos(offsetId string, limit int, album string) *PhotoList {
	// check db
	if err := t.touchDB(); err != nil {
		return nil
	}
	log.Debugf("getting photos: offsetId: %s, limit: %d, album: %s", offsetId, limit, album)

	// query for available hashes
	a := t.Datastore.Albums().GetAlbumByName(album)
	if a == nil {
		return &PhotoList{Hashes: make([]string, 0)}
	}
	list := t.Datastore.Photos().GetPhotos(offsetId, limit, "album='"+a.Id+"'")

	// return json list of hashes
	res := &PhotoList{
		Hashes: make([]string, len(list)),
	}
	for i := range list {
		res.Hashes[i] = list[i].Cid
	}
	log.Debugf("found %d photos in thread %s", len(list), album)

	return res
}

// GetFile cats data from the ipfs node.
// e.g., Qm../thumb, Qm../photo, Qm../meta, Qm../caption
// Note: album is looked up if not present
// TODO: shouldn't this be available offline for local (pinned content)?
func (t *TextileNode) GetFile(path string, album *trepo.PhotoAlbum) ([]byte, error) {
	if !t.Online() {
		return nil, ErrNodeNotRunning
	}

	// get bytes
	cb, err := t.getDataAtPath(path)
	if err != nil {
		log.Errorf("error getting file data: %s", err)
		return nil, err
	}

	// normalize path
	ip, err := coreapi.ParsePath(path)
	if err != nil {
		log.Errorf("error parsing path: %s", err)
		return nil, err
	}

	// parse root hash of path
	tmp := strings.Split(ip.String(), "/")
	if len(tmp) < 3 {
		err := errors.New(fmt.Sprintf("bad path: %s", path))
		log.Error(err.Error())
		return nil, err
	}
	ci := tmp[2]

	// look up key for decryption
	if album == nil {
		_, album, err = t.LoadPhotoAndAlbum(ci)
		if err != nil {
			log.Error(err.Error())
			return nil, err
		}
	}

	// finally, decrypt
	b, err := net.Decrypt(album.Key, cb)
	if err != nil {
		log.Errorf("error decrypting file: %s", err)
		return nil, err
	}

	return b, err
}

// GetFileBase64 returns data encoded as base64 under an ipfs path
func (t *TextileNode) GetFileBase64(path string) (string, error) {
	b, err := t.GetFile(path, nil)
	if err != nil {
		return "error", err
	}
	return base64.StdEncoding.EncodeToString(b), nil
}

// GetMetaData returns metadata under a hash
// TODO: shouldn't this be available offline for local (pinned content)?
func (t *TextileNode) GetMetaData(hash string, album *trepo.PhotoAlbum) (*photos.Metadata, error) {
	if !t.Online() {
		return nil, ErrNodeNotRunning
	}
	b, err := t.GetFile(fmt.Sprintf("%s/meta", hash), album)
	if err != nil {
		log.Errorf("error getting meta file with hash: %s: %s", hash, err)
		return nil, err
	}
	var data *photos.Metadata
	err = json.Unmarshal(b, &data)
	if err != nil {
		log.Errorf("error unmarshaling meta file with hash: %s: %s", hash, err)
		return nil, err
	}

	return data, nil
}

func (t *TextileNode) TouchDB() error {
	return t.touchDB()
}

// GetLastHash return the caption under a hash
// TODO: shouldn't this be available offline for local (pinned content)?
func (t *TextileNode) GetCaption(hash string, album *trepo.PhotoAlbum) (string, error) {
	if !t.Online() {
		return "", ErrNodeNotRunning
	}
	b, err := t.GetFile(fmt.Sprintf("%s/caption", hash), album)
	if err != nil {
		log.Errorf("error getting caption file with hash: %s: %s", hash, err)
		return "", err
	}

	return string(b), nil
}

// GetLastHash return the last update's root cid under a hash
// TODO: shouldn't this be available offline for local (pinned content)?
func (t *TextileNode) GetLastHash(hash string, album *trepo.PhotoAlbum) (string, error) {
	if !t.Online() {
		return "", ErrNodeNotRunning
	}
	b, err := t.GetFile(fmt.Sprintf("%s/last", hash), album)
	if err != nil {
		log.Errorf("error getting last hash file with hash: %s: %s", hash, err)
		return "", err
	}

	return string(b), nil
}

// LoadPhotoAndAlbum get the indexed photo and album for a hash
func (t *TextileNode) LoadPhotoAndAlbum(hash string) (*trepo.PhotoSet, *trepo.PhotoAlbum, error) {
	if !t.Online() {
		return nil, nil, ErrNodeNotRunning
	}
	ph := t.Datastore.Photos().GetPhoto(hash)
	if ph == nil {
		return nil, nil, errors.New(fmt.Sprintf("photo %s not found", hash))
	}
	album := t.Datastore.Albums().GetAlbum(ph.AlbumID)
	if album == nil {
		return nil, nil, errors.New(fmt.Sprintf("could not find album: %s", ph.AlbumID))
	}
	return ph, album, nil
}

// UnmarshalPrivatePeerKey returns a PrivKey instance from the base64 encoded ipfs peer key
func (t *TextileNode) UnmarshalPrivatePeerKey() (libp2p.PrivKey, error) {
	if !t.Online() {
		return nil, ErrNodeNotRunning
	}
	cfg, err := t.Context.GetConfig()
	if err != nil {
		return nil, err
	}
	skb, err := base64.StdEncoding.DecodeString(cfg.Identity.PrivKey)
	if err != nil {
		return nil, err
	}
	sk, err := libp2p.UnmarshalPrivateKey(skb)
	if err != nil {
		return nil, err
	}

	// check
	id2, err := peer.IDFromPrivateKey(sk)
	if err != nil {
		return nil, err
	}
	if id2 != t.IpfsNode.Identity {
		return nil, fmt.Errorf("private key in config does not match id: %s != %s", t.IpfsNode.Identity, id2)
	}

	return sk, nil
}

// GetPublicPeerKeyString returns the base64 encoded public ipfs peer key
func (t *TextileNode) GetPublicPeerKeyString() (string, error) {
	sk, err := t.UnmarshalPrivatePeerKey()
	if err != nil {
		log.Errorf("error unmarshaling priv peer key: %s", err)
		return "", err
	}
	pkb, err := sk.GetPublic().Bytes()
	if err != nil {
		log.Errorf("error getting pub key bytes: %s", err)
		return "", err
	}

	return base64.StdEncoding.EncodeToString(pkb), nil
}

// Publish and ping
func (t *TextileNode) Publish(topic string, payload []byte) error {
	if len(t.IpfsNode.PeerHost.Network().Peers()) == 0 {
		return errors.New("no peers, aborting")
	}
	out, err := t.ConnectPeer([]string{fmt.Sprintf("/p2p-circuit/ipfs/%s", tconfig.RemoteRelayNode)})
	if err != nil {
		return err
	}
	for _, o := range out {
		log.Debug(o)
	}
	return t.IpfsNode.Floodsub.Publish(topic, payload)
}

// ConnectPeer connect to another ipfs peer (i.e., ipfs swarm connect)
func (t *TextileNode) ConnectPeer(addrs []string) ([]string, error) {
	if t.IpfsNode.PeerHost == nil {
		return nil, errors.New("not online")
	}

	snet, ok := t.IpfsNode.PeerHost.Network().(*swarm.Network)
	if !ok {
		return nil, errors.New("peerhost network was not swarm")
	}

	swrm := snet.Swarm()

	pis, err := peersWithAddresses(addrs)
	if err != nil {
		return nil, err
	}

	output := make([]string, len(pis))
	for i, pi := range pis {
		swrm.Backoff().Clear(pi.ID)

		output[i] = "connect " + pi.ID.Pretty()

		err := t.IpfsNode.PeerHost.Connect(t.IpfsNode.Context(), pi)
		if err != nil {
			return nil, fmt.Errorf("%s failure: %s", output[i], err)
		}
		output[i] += " success"
	}
	return output, nil
}

// PingPeer pings a peer num times, returning the result to out chan
func (t *TextileNode) PingPeer(addrs string, num int, out chan string) error {
	if !t.Online() {
		return ErrNodeNotRunning
	}
	addr, pid, err := parsePeerParam(addrs)
	if addr != nil {
		t.IpfsNode.Peerstore.AddAddr(pid, addr, pstore.TempAddrTTL) // temporary
	}

	if len(t.IpfsNode.Peerstore.Addrs(pid)) == 0 {
		// Make sure we can find the node in question
		log.Debugf("looking up peer: %s", pid.Pretty())

		ctx, cancel := context.WithTimeout(t.IpfsNode.Context(), pingTimeout)
		defer cancel()
		p, err := t.IpfsNode.Routing.FindPeer(ctx, pid)
		if err != nil {
			err = fmt.Errorf("peer lookup error: %s", err)
			log.Errorf(err.Error())
			return err
		}
		t.IpfsNode.Peerstore.AddAddrs(p.ID, p.Addrs, pstore.TempAddrTTL)
	}

	ctx, cancel := context.WithTimeout(t.IpfsNode.Context(), pingTimeout*time.Duration(num))
	defer cancel()
	pings, err := t.IpfsNode.Ping.Ping(ctx, pid)
	if err != nil {
		log.Errorf("error pinging peer %s: %s", pid.Pretty(), err)
		return err
	}

	var done bool
	var total time.Duration
	for i := 0; i < num && !done; i++ {
		select {
		case <-ctx.Done():
			done = true
			close(out)
			break
		case t, ok := <-pings:
			if !ok {
				done = true
				close(out)
				break
			}
			total += t
			msg := fmt.Sprintf("ping %s completed after %f seconds", pid.Pretty(), t.Seconds())
			select {
			case out <- msg:
			default:
			}
			log.Debug(msg)
			time.Sleep(time.Second)
		}
	}

	return nil
}

// RepublishLatestUpdate publishes latest in a single album
func (t *TextileNode) RepublishLatestUpdate(album *trepo.PhotoAlbum) {
	// find latest local update
	recent := t.Datastore.Photos().GetPhotos("", 1, "album='"+album.Id+"' and local=1")
	var latest string
	if len(recent) > 0 {
		latest = recent[0].Cid
	} else {
		latest = "ping"
	}

	// publish it
	log.Debugf("starting re-publish...")
	if err := t.Publish(album.Id, []byte(latest)); err != nil {
		log.Errorf("error re-publishing update: %s", err)
		return
	}
	log.Debugf("re-published %s to %s", latest, album.Id)
}

// registerGatewayHandler registers a handler for the gateway
func (t *TextileNode) registerGatewayHandler() {
	defer func() {
		if recover() != nil {
			log.Debug("gateway handler already registered")
		}
	}()
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		_, password, ok := r.BasicAuth()
		log.Debugf("gateway request: %s", r.URL.RequestURI())
		if ok == false {
			w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
			w.WriteHeader(401)
			return
		}

		// parse root hash of path
		tmp := strings.Split(r.URL.Path, "/")
		if len(tmp) < 3 {
			err := errors.New(fmt.Sprintf("bad path: %s", r.URL.Path))
			log.Error(err.Error())
			return
		}

		ci := strings.Join(tmp[2:], "/")
		if password != t.HashPasses[ci] {
			log.Debugf("wrong password: %s", ci, t.HashPasses[ci])
			w.WriteHeader(401)
			return
		}

		// invalidate previous password
		// t.HashPasses[username] = ksuid.New().String()

		b, err := t.GetFile(r.URL.Path, nil)
		if err != nil {
			log.Errorf("error decrypting path %s: %s", r.URL.Path, err)
			w.WriteHeader(400)
			return
		}

		w.Write(b)
	})
}

// startGateway starts the secure HTTP gatway server
func (t *TextileNode) startGateway() (<-chan error, error) {
	// try to register our handler
	t.registerGatewayHandler()

	// Start the HTTPS server in a goroutine
	errc := make(chan error)
	go func() {
		errc <- t.GatewayProxy.ListenAndServe()
		close(errc)
	}()
	log.Infof("decrypting gateway (readonly) server listening at %s\n", t.GatewayProxy.Addr)

	return errc, nil
}

// startRepublishing continuously publishes the latest update in each thread
func (t *TextileNode) startRepublishing() {
	// do it once right away
	t.republishLatestUpdates()

	// create a never-ending ticker
	ticker := time.NewTicker(roomRepublishInterval)
	defer func() {
		ticker.Stop()
		defer func() {
			if recover() != nil {
				log.Error("republishing ticker already stopped")
			}
		}()
	}()
	go func() {
		for range ticker.C {
			t.republishLatestUpdates()
		}
	}()

	// we can stop when the node stops
	for {
		if !t.Online() {
			return
		}
		select {
		case <-t.IpfsNode.Context().Done():
			log.Info("republishing stopped")
			return
		}
	}
}

// republishLatestUpdates interates through albums, publishing each ones latest update
func (t *TextileNode) republishLatestUpdates() {
	// do this for each album
	albums := t.Datastore.Albums().GetAlbums("")
	for _, album := range albums {
		go func(a trepo.PhotoAlbum) {
			t.RepublishLatestUpdate(&a)
		}(album)
	}
}

// handleRoomUpdate tries to recursively process an update sent to a thread
func (t *TextileNode) handleRoomUpdate(msg *floodsub.Message, aid string, api iface.CoreAPI, datac chan ThreadUpdate) error {
	// unpack from
	from := msg.GetFrom().Pretty()
	if from == t.IpfsNode.Identity.Pretty() {
		return nil
	}

	// unpack message data
	data := string(msg.GetData())
	if data == "ping" {
		return nil
	}

	// determine if this is from a relay node
	tmp := strings.Split(data, ":")
	var hash string
	if len(tmp) > 1 && tmp[0] == "relay" {
		hash = tmp[1]
		from = fmt.Sprintf("relay:%s", from)
	} else {
		hash = tmp[0]
	}
	log.Debugf("got update from %s in room %s", from, aid)

	// recurse back in time starting at this hash
	err := t.handleHash(hash, aid, api, datac)
	if err != nil {
		return err
	}

	return nil
}

// handleHash tries to process an update sent to a thread
func (t *TextileNode) handleHash(hash string, aid string, api iface.CoreAPI, datac chan ThreadUpdate) error {
	// look up the album
	a := t.Datastore.Albums().GetAlbum(aid)
	if a == nil {
		err := errors.New(fmt.Sprintf("could not find album with id: %s", aid))
		return err
	}

	// first update?
	if hash == "" {
		log.Debugf("found genesis update, aborting")
		return nil
	}
	log.Debugf("handling update: %s...", hash)

	// check if we aleady have this hash
	set := t.Datastore.Photos().GetPhoto(hash)
	if set != nil {
		log.Debugf("update %s exists, aborting", hash)
		return nil
	}

	// pin the dag structure
	log.Debugf("pinning %s...", hash)
	if err := t.pinPath(hash, api, false); err != nil {
		return err
	}

	// pin the thumbnail
	log.Debugf("pinning %s/thumb...", hash)
	if err := t.pinPath(fmt.Sprintf("%s/thumb", hash), api, false); err != nil {
		return err
	}

	// pin the meta
	log.Debugf("pinning %s/meta...", hash)
	if err := t.pinPath(fmt.Sprintf("%s/meta", hash), api, false); err != nil {
		return err
	}

	// pin the last hash
	log.Debugf("pinning %s/last...", hash)
	if err := t.pinPath(fmt.Sprintf("%s/last", hash), api, false); err != nil {
		return err
	}

	// pin the caption (may not exist, ignore error)
	log.Debugf("pinning %s/caption...", hash)
	t.pinPath(fmt.Sprintf("%s/caption", hash), api, false)

	// unpack data set
	log.Debugf("unpacking %s...", hash)
	md, err := t.GetMetaData(hash, a)
	if err != nil {
		return err
	}
	last, err := t.GetLastHash(hash, a)
	if err != nil {
		return err
	}
	caption, err := t.GetCaption(hash, a)
	if err != nil {
		caption = ""
	}

	// index
	log.Debugf("indexing %s...", hash)
	set = &trepo.PhotoSet{
		Cid:      hash,
		LastCid:  last,
		AlbumID:  aid,
		MetaData: *md,
		Caption:  caption,
		IsLocal:  false,
	}
	err = t.Datastore.Photos().Put(set)
	if err != nil {
		return err
	}

	// don't block on the send since nobody might be listening
	select {
	case datac <- ThreadUpdate{Cid: hash, Thread: a.Name, ThreadID: a.Id}:
	default:
	}
	defer func() {
		if recover() != nil {
			log.Error("update channel already closed")
		}
	}()

	// check last hash
	return t.handleHash(last, aid, api, datac)
}

// getDataAtPath cats any data under an ipfs path
func (t *TextileNode) getDataAtPath(path string) ([]byte, error) {
	// convert string to an ipfs path
	ip, err := coreapi.ParsePath(path)
	if err != nil {
		return nil, err
	}

	api := coreapi.NewCoreAPI(t.IpfsNode)
	ctx, cancel := context.WithTimeout(t.IpfsNode.Context(), catTimeout)
	defer cancel()
	r, err := api.Unixfs().Cat(ctx, ip)
	if err != nil {
		return nil, err
	}
	defer r.Close()
	defer func() {
		if recover() != nil {
			log.Debug("node stopped")
		}
	}()

	return ioutil.ReadAll(r)
}

// pinPath takes an ipfs path string and pins it
func (t *TextileNode) pinPath(path string, api iface.CoreAPI, recursive bool) error {
	ip, err := coreapi.ParsePath(path)
	if err != nil {
		log.Errorf("error pinning path: %s, recursive: %t: %s", path, recursive, err)
		return err
	}
	ctx, cancel := context.WithTimeout(t.IpfsNode.Context(), pinTimeout)
	defer cancel()
	if err := api.Pin().Add(ctx, ip, options.Pin.Recursive(recursive)); err != nil {
		return err
	}
	defer func() {
		if recover() != nil {
			log.Debug("node stopped")
		}
	}()
	return nil
}

// touchDB ensures that we have a good db connection
func (t *TextileNode) touchDB() error {
	if err := t.Datastore.Ping(); err != nil {
		log.Debug("re-opening datastore...")
		sqliteDB, err := db.Create(t.RepoPath, "")
		if err != nil {
			log.Errorf("error re-opening datastore: %s", err)
			return err
		}
		t.Datastore = sqliteDB
	}
	return nil
}
<file_sep>package main

import (
	"fmt"

	"github.com/asticode/go-astilectron"
	"github.com/asticode/go-astilog"

	"github.com/textileio/textile-go/core"
)

var albumID string

func start(_ *astilectron.Astilectron, iw *astilectron.Window, _ *astilectron.Menu, _ *astilectron.Tray, _ *astilectron.Menu) error {
	astilog.Info("TEXTILE STARTED")

	astilog.Info("SENDING COOKIE INFO")
	sendData(iw, "login.cookie", map[string]interface{}{
		"name":    "SessionId",
		"value":   textile.GatewayPassword,
		"gateway": gateway,
	})

	// check if we're configured yet
	a := textile.Datastore.Albums().GetAlbumByName("mobile")
	if a != nil {
		albumID = a.Id

		// can join room
		astilog.Info("FOUND MOBILE ALBUM")

		// tell app we're ready and send initial html
		sendData(iw, "sync.ready", map[string]interface{}{
			"html": getPhotosHTML(),
		})

	} else {
		// otherwise, start onboaring
		astilog.Info("COULD NOT FIND MOBILE ALBUM")
		astilog.Info("STARTING PAIRING")

		go func() {
			// sub to own peer id for pairing setup and wait
			textile.WaitForRoom()

			a := textile.Datastore.Albums().GetAlbumByName("mobile")
			if a == nil {
				astilog.Error("failed to create mobile album")
				return
			}
			albumID = a.Id

			// let the app know we're done pairing
			sendMessage(iw, "onboard.complete")

			// and that we're ready to go
			sendData(iw, "sync.ready", map[string]interface{}{
				"html": getPhotosHTML(),
			})
		}()
		sendMessage(iw, "onboard.start")
	}

	return nil
}

func joinRoom(iw *astilectron.Window) error {
	astilog.Info("STARTING SYNC")

	datac := make(chan core.ThreadUpdate)
	go textile.JoinRoom(albumID, datac)
	for {
		select {
		case update, ok := <-datac:
			if !ok {
				return nil
			}
			sendData(iw, "sync.data", map[string]interface{}{
				"update":  update,
				"gateway": gateway,
			})
		}
	}
}

func getPhotosHTML() string {
	var html string
	for _, photo := range textile.Datastore.Photos().GetPhotos("", -1, "") {
		ph := fmt.Sprintf("%s/ipfs/%s/photo", gateway, photo.Cid)
		th := fmt.Sprintf("%s/ipfs/%s/thumb", gateway, photo.Cid)
		md := fmt.Sprintf("%s/ipfs/%s/meta", gateway, photo.Cid)
		img := fmt.Sprintf("<img src=\"%s\" />", th)
		html += fmt.Sprintf("<div id=\"%s\" class=\"grid-item\" ondragstart=\"imageDragStart(event);\" draggable=\"true\" data-url=\"%s\" data-meta=\"%s\">%s</div>", photo.Cid, ph, md, img)
	}
	return html
}

func sendMessage(iw *astilectron.Window, name string) {
	iw.SendMessage(map[string]string{"name": name})
}

func sendData(iw *astilectron.Window, name string, data map[string]interface{}) {
	data["name"] = name
	iw.SendMessage(data)
}
<file_sep>package db

import (
	"database/sql"
	"strconv"
	"sync"
	"time"

	"github.com/textileio/textile-go/repo"
	"github.com/textileio/textile-go/repo/photos"
)

type PhotoDB struct {
	modelStore
}

func NewPhotoStore(db *sql.DB, lock *sync.Mutex) repo.PhotoStore {
	return &PhotoDB{modelStore{db, lock}}
}

func (c *PhotoDB) Put(set *repo.PhotoSet) error {
	c.lock.Lock()
	defer c.lock.Unlock()

	tx, err := c.db.Begin()
	if err != nil {
		return err
	}
	stm := `insert into photos(cid, lastCid, album, name, ext, username, peerId, created, added, latitude, longitude, local, caption) values(?,?,?,?,?,?,?,?,?,?,?,?,?)`
	stmt, err := tx.Prepare(stm)
	if err != nil {
		log.Errorf("error in tx prepare: %s", err)
		return err
	}

	localInt := 0
	if set.IsLocal {
		localInt = 1
	}

	defer stmt.Close()
	_, err = stmt.Exec(
		set.Cid,
		set.LastCid,
		set.AlbumID,
		set.MetaData.Name,
		set.MetaData.Ext,
		set.MetaData.Username,
		set.MetaData.PeerID,
		int(set.MetaData.Created.Unix()),
		int(set.MetaData.Added.Unix()),
		set.MetaData.Latitude,
		set.MetaData.Longitude,
		localInt,
		set.Caption,
	)
	if err != nil {
		tx.Rollback()
		log.Errorf("error in db exec: %s", err)
		return err
	}
	tx.Commit()
	return nil
}

func (c *PhotoDB) GetPhotos(offsetId string, limit int, query string) []repo.PhotoSet {
	c.lock.Lock()
	defer c.lock.Unlock()
	var stm string
	if offsetId != "" {
		q := ""
		if query != "" {
			q = query + " and "
		}
		stm = "select * from photos where " + q + "added<(select added from photos where cid='" + offsetId + "') order by added desc limit " + strconv.Itoa(limit) + " ;"
	} else {
		q := ""
		if query != "" {
			q = "where " + query + " "
		}
		stm = "select * from photos " + q + "order by added desc limit " + strconv.Itoa(limit) + ";"
	}
	return c.handleQuery(stm)
}

func (c *PhotoDB) GetPhoto(cid string) *repo.PhotoSet {
	c.lock.Lock()
	defer c.lock.Unlock()
	ret := c.handleQuery("select * from photos where cid='" + cid + "';")
	if len(ret) == 0 {
		return nil
	}
	return &ret[0]
}

func (c *PhotoDB) DeletePhoto(cid string) error {
	c.lock.Lock()
	defer c.lock.Unlock()
	_, err := c.db.Exec("delete from photos where cid=?", cid)
	return err
}

func (c *PhotoDB) handleQuery(stm string) []repo.PhotoSet {
	var ret []repo.PhotoSet
	rows, err := c.db.Query(stm)
	if err != nil {
		log.Errorf("error in db query: %s", err)
		return nil
	}
	for rows.Next() {
		var cid, lastCid, album, name, ext, username, peerId string
		var createdInt, addedInt int
		var latitude, longitude float64
		var localInt int
		var caption string
		if err := rows.Scan(&cid, &lastCid, &album, &name, &ext, &username, &peerId, &createdInt, &addedInt, &latitude, &longitude, &localInt, &caption); err != nil {
			log.Errorf("error in db scan: %s", err)
			continue
		}
		created := time.Unix(int64(createdInt), 0)
		added := time.Unix(int64(addedInt), 0)
		local := false
		if localInt == 1 {
			local = true
		}
		photo := repo.PhotoSet{
			Cid:     cid,
			LastCid: lastCid,
			AlbumID: album,
			MetaData: photos.Metadata{
				Name:      name,
				Ext:       ext,
				Username:  username,
				PeerID:    peerId,
				Created:   created,
				Added:     added,
				Latitude:  latitude,
				Longitude: longitude,
			},
			Caption: caption,
			IsLocal: local,
		}
		ret = append(ret, photo)
	}
	return ret
}
