1// Copyright 2020 Keybase, Inc. All rights reserved. Use of
2// this source code is governed by the included BSD license.
3
4package opensearch
5
6import (
7	"errors"
8	"sort"
9	"strings"
10	"sync"
11	"time"
12
13	"github.com/keybase/client/go/libkb"
14	"github.com/keybase/client/go/protocol/gregor1"
15	"github.com/keybase/client/go/protocol/keybase1"
16)
17
18type teamMap map[keybase1.TeamID]keybase1.TeamSearchItem
19
20const refreshThreshold = time.Hour
21
22var lastRefresh time.Time
23
24type teamSearchResult struct {
25	Results []keybase1.TeamSearchItem `json:"results"`
26	Status  libkb.AppStatus
27}
28
29func (r *teamSearchResult) GetAppStatus() *libkb.AppStatus {
30	return &r.Status
31}
32
33type teamRefreshResult struct {
34	keybase1.TeamSearchExport
35	Status libkb.AppStatus
36}
37
38func (r *teamRefreshResult) GetAppStatus() *libkb.AppStatus {
39	return &r.Status
40}
41
42type storageItem struct {
43	Items     teamMap
44	Suggested []keybase1.TeamID
45	Hash      string
46}
47
48func dbKey() libkb.DbKey {
49	return libkb.DbKey{
50		Typ: libkb.DBOpenTeams,
51		Key: "v0",
52	}
53}
54
55func getCurrentHash(mctx libkb.MetaContext) (hash string) {
56	var si storageItem
57	found, err := mctx.G().GetKVStore().GetInto(&si, dbKey())
58	if err != nil {
59		mctx.Debug("OpenSearch.getCurrentHash: failed to read: %s", err)
60		return ""
61	}
62	if !found {
63		return ""
64	}
65	return si.Hash
66}
67
68func getOpenTeams(mctx libkb.MetaContext) (res storageItem, err error) {
69	get := func() (res storageItem, err error) {
70		found, err := mctx.G().GetKVStore().GetInto(&res, dbKey())
71		if err != nil {
72			return res, err
73		}
74		if !found {
75			return res, errors.New("no open teams found")
76		}
77		return res, nil
78	}
79	if res, err = get(); err != nil {
80		mctx.Debug("OpenSearch.getOpenTeams: failed to get open teams, refreshing")
81		refreshOpenTeams(mctx, true)
82		return get()
83	}
84	return res, nil
85}
86
87var refreshMu sync.Mutex
88
89func refreshOpenTeams(mctx libkb.MetaContext, force bool) {
90	tracer := mctx.G().CTimeTracer(mctx.Ctx(), "OpenSearch.refreshOpenTeams", true)
91	defer tracer.Finish()
92	refreshMu.Lock()
93	defer refreshMu.Unlock()
94	if !force && time.Since(lastRefresh) < refreshThreshold {
95		return
96	}
97	saved := true
98	defer func() {
99		if saved {
100			lastRefresh = time.Now()
101		}
102	}()
103	hash := getCurrentHash(mctx)
104	mctx.Debug("OpenSearch.refreshOpenTeams: using hash: %s", hash)
105	a := libkb.NewAPIArg("teamsearch/refresh")
106	a.Args = libkb.HTTPArgs{}
107	a.Args["hash"] = libkb.S{Val: hash}
108	a.SessionType = libkb.APISessionTypeREQUIRED
109	var apiRes teamRefreshResult
110	if err := mctx.G().API.GetDecode(mctx, a, &apiRes); err != nil {
111		mctx.Debug("OpenSearch.refreshOpenTeams: failed to fetch open teams: %s", err)
112		saved = false
113		return
114	}
115	if len(apiRes.Items) == 0 {
116		mctx.Debug("OpenSearch.refreshOpenTeams: hash match, standing pat")
117		return
118	}
119	mctx.Debug("OpenSearch.refreshOpenTeams: received %d teams, suggested: %d", len(apiRes.Items),
120		len(apiRes.Suggested))
121	var out storageItem
122	out.Items = apiRes.Items
123	out.Suggested = apiRes.Suggested
124	out.Hash = apiRes.Hash()
125	if err := mctx.G().GetKVStore().PutObj(dbKey(), nil, out); err != nil {
126		mctx.Debug("OpenSearch.refreshOpenTeams: failed to put: %s", err)
127		saved = false
128		return
129	}
130}
131
132// Local performs a local search for Keybase open teams.
133func Local(mctx libkb.MetaContext, query string, limit int) (res []keybase1.TeamSearchItem, err error) {
134	var si storageItem
135	mctx = mctx.WithLogTag("OTS")
136	tracer := mctx.G().CTimeTracer(mctx.Ctx(), "OpenSearch.Local", true)
137	defer tracer.Finish()
138	defer func() {
139		go refreshOpenTeams(mctx, false)
140	}()
141	if si, err = getOpenTeams(mctx); err != nil {
142		return res, err
143	}
144	query = strings.ToLower(query)
145	var results []rankedSearchItem
146	if len(query) == 0 {
147		for index, id := range si.Suggested {
148			results = append(results, rankedSearchItem{
149				item:  si.Items[id],
150				score: 100.0 + float64((len(si.Suggested) - index)),
151			})
152		}
153	} else {
154		for _, item := range si.Items {
155			rankedItem := rankedSearchItem{
156				item: item,
157			}
158			rankedItem.score = rankedItem.Score(query)
159			if FilterScore(rankedItem.score) {
160				continue
161			}
162			results = append(results, rankedItem)
163		}
164	}
165	sort.Slice(results, func(i, j int) bool {
166		return results[i].score > results[j].score
167	})
168	for index, r := range results {
169		if index >= limit {
170			break
171		}
172		if r.item.InTeam, err = mctx.G().ChatHelper.InTeam(mctx.Ctx(),
173			gregor1.UID(mctx.G().GetMyUID().ToBytes()), r.item.Id); err != nil {
174			mctx.Debug("OpenSearch.Local: failed to get inTeam for: %s err: %s", r.item.Id, err)
175		}
176		res = append(res, r.item)
177	}
178	return res, nil
179}
180
181func Remote(mctx libkb.MetaContext, query string, limit int) ([]keybase1.TeamSearchItem, error) {
182	tracer := mctx.G().CTimeTracer(mctx.Ctx(), "OpenSearch.Remote", true)
183	defer tracer.Finish()
184
185	a := libkb.NewAPIArg("teamsearch/search")
186	a.Args = libkb.HTTPArgs{}
187	a.Args["query"] = libkb.S{Val: query}
188	a.Args["limit"] = libkb.I{Val: limit}
189
190	a.SessionType = libkb.APISessionTypeREQUIRED
191	var res teamSearchResult
192	if err := mctx.G().API.GetDecode(mctx, a, &res); err != nil {
193		return nil, err
194	}
195	return res.Results, nil
196}
197