github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/git/get.go (about) 1 package git 2 3 import ( 4 "context" 5 "encoding/base64" 6 "fmt" 7 "sort" 8 "sync" 9 "time" 10 11 "github.com/keybase/client/go/libkb" 12 "github.com/keybase/client/go/protocol/keybase1" 13 "github.com/keybase/client/go/teams" 14 "github.com/keybase/go-codec/codec" 15 "golang.org/x/sync/errgroup" 16 ) 17 18 type ServerResponseRepo struct { 19 TeamID keybase1.TeamID `json:"team_id"` 20 RepoID keybase1.RepoID `json:"repo_id"` 21 CTime time.Time `json:"ctime"` 22 MTime time.Time `json:"mtime"` 23 EncryptedMetadata string `json:"encrypted_metadata"` 24 EncryptionVersion int `json:"encryption_version"` 25 Nonce string `json:"nonce"` 26 KeyGeneration keybase1.PerTeamKeyGeneration `json:"key_generation"` 27 LastModifyingUID keybase1.UID `json:"last_writer_uid"` 28 LastModifyingDeviceID keybase1.DeviceID `json:"last_writer_device_id"` 29 ChatConvID string `json:"chat_conv_id"` 30 ChatDisabled bool `json:"chat_disabled"` 31 IsImplicit bool `json:"is_implicit"` 32 Role keybase1.TeamRole `json:"role"` 33 } 34 35 type ServerResponse struct { 36 Repos []ServerResponseRepo `json:"repos"` 37 Status libkb.AppStatus `json:"status"` 38 } 39 40 type ByRepoMtime []keybase1.GitRepoResult 41 42 func (c ByRepoMtime) Len() int { return len(c) } 43 func (c ByRepoMtime) Swap(i, j int) { c[i], c[j] = c[j], c[i] } 44 func (c ByRepoMtime) Less(i, j int) bool { 45 res1, err1 := c[i].GetIfOk() 46 res2, err2 := c[j].GetIfOk() 47 if err1 != nil || err2 != nil { 48 return false 49 } 50 51 return res1.ServerMetadata.Mtime < res2.ServerMetadata.Mtime 52 } 53 54 var _ libkb.APIResponseWrapper = (*ServerResponse)(nil) 55 56 // For GetDecode. 57 func (r *ServerResponse) GetAppStatus() *libkb.AppStatus { 58 return &r.Status 59 } 60 61 func formatRepoURL(folder keybase1.FolderHandle, repoName string) string { 62 return "keybase://" + folder.ToString() + "/" + repoName 63 } 64 65 // The GUI needs a way to refer to repos 66 func formatUniqueRepoID(teamID keybase1.TeamID, repoID keybase1.RepoID) string { 67 return string(teamID) + "_" + string(repoID) 68 } 69 70 // Implicit teams need to be converted back into the folder that matches their 71 // display name. Regular teams become a regular team folder. 72 func folderFromTeamID(ctx context.Context, g *libkb.GlobalContext, teamID keybase1.TeamID, isImplicit bool) (keybase1.FolderHandle, error) { 73 if isImplicit { 74 return folderFromTeamIDImplicit(ctx, g, teamID) 75 } 76 return folderFromTeamIDNamed(ctx, g, teamID) 77 } 78 79 func folderFromTeamIDNamed(ctx context.Context, g *libkb.GlobalContext, teamID keybase1.TeamID) (keybase1.FolderHandle, error) { 80 name, err := teams.ResolveIDToName(ctx, g, teamID) 81 if err != nil { 82 return keybase1.FolderHandle{}, err 83 } 84 return keybase1.FolderHandle{ 85 Name: name.String(), 86 FolderType: keybase1.FolderType_TEAM, 87 }, nil 88 } 89 90 // folderFromTeamIDImplicit converts from a teamID for implicit teams 91 func folderFromTeamIDImplicit(ctx context.Context, g *libkb.GlobalContext, teamID keybase1.TeamID) (keybase1.FolderHandle, error) { 92 93 team, err := teams.Load(ctx, g, keybase1.LoadTeamArg{ 94 ID: teamID, 95 Public: teamID.IsPublic(), 96 }) 97 if err != nil { 98 return keybase1.FolderHandle{}, err 99 } 100 if !team.IsImplicit() { 101 return keybase1.FolderHandle{}, fmt.Errorf("Expected an implicit team, but team load said otherwise (%s)", teamID) 102 } 103 104 // TODO: This function doesn't currently support conflict info. 105 name, err := team.ImplicitTeamDisplayNameString(ctx) 106 if err != nil { 107 return keybase1.FolderHandle{}, err 108 } 109 var folderType keybase1.FolderType 110 if team.IsPublic() { 111 folderType = keybase1.FolderType_PUBLIC 112 } else { 113 folderType = keybase1.FolderType_PRIVATE 114 } 115 return keybase1.FolderHandle{ 116 Name: name, 117 FolderType: folderType, 118 }, nil 119 } 120 121 // If folder is nil, get for all folders. 122 func getMetadataInner(ctx context.Context, g *libkb.GlobalContext, folder *keybase1.FolderHandle) ([]keybase1.GitRepoResult, error) { 123 mctx := libkb.NewMetaContext(ctx, g) 124 teamer := NewTeamer(g) 125 126 apiArg := libkb.APIArg{ 127 Endpoint: "kbfs/git/team/get", 128 SessionType: libkb.APISessionTypeREQUIRED, 129 Args: libkb.HTTPArgs{}, // a limit parameter exists, default 100, and we don't currently set it 130 } 131 132 // The team_id parameter is optional. Add it in if the caller supplied it. 133 if folder != nil { 134 teamIDVis, err := teamer.LookupOrCreate(ctx, *folder) 135 if err != nil { 136 return nil, err 137 } 138 apiArg.Args["team_id"] = libkb.S{Val: string(teamIDVis.TeamID)} 139 } 140 var serverResponse ServerResponse 141 err := mctx.G().GetAPI().GetDecode(mctx, apiArg, &serverResponse) 142 if err != nil { 143 return nil, err 144 } 145 146 // Unbox the repos in parallel 147 repoCh := make(chan ServerResponseRepo) 148 eg, ctx := errgroup.WithContext(ctx) 149 eg.Go(func() error { 150 defer close(repoCh) 151 for _, responseRepo := range serverResponse.Repos { 152 select { 153 case repoCh <- responseRepo: 154 case <-ctx.Done(): 155 return ctx.Err() 156 } 157 } 158 return nil 159 }) 160 161 // Initializing the results list to non-nil means that we end up seeing 162 // "[]" instead of "null" in the final JSON output on the CLI, which is 163 // preferable. 164 resultList := []keybase1.GitRepoResult{} 165 var resLock sync.Mutex 166 var firstErr error 167 var anySuccess bool 168 numUnboxThreads := 2 169 for i := 0; i < numUnboxThreads; i++ { 170 eg.Go(func() error { 171 for responseRepo := range repoCh { 172 info, skip, err := getMetadataInnerSingle(ctx, g, folder, responseRepo) 173 174 resLock.Lock() 175 if err != nil { 176 if firstErr == nil { 177 firstErr = err 178 } 179 mctx.Debug("git.getMetadataInner error (team:%v, repo:%v): %v", responseRepo.TeamID, responseRepo.RepoID, err) 180 resultList = append(resultList, keybase1.NewGitRepoResultWithErr(err.Error())) 181 } else if !skip { 182 anySuccess = true 183 resultList = append(resultList, keybase1.NewGitRepoResultWithOk(*info)) 184 } 185 resLock.Unlock() 186 187 } 188 return nil 189 }) 190 } 191 if err := eg.Wait(); err != nil { 192 return resultList, err 193 } 194 sort.Sort(ByRepoMtime(resultList)) 195 196 // If there were no repos, return ok 197 // If all repos failed, return the first error (something is probably wrong that's not repo-specific) 198 // If no repos failed, return ok 199 if len(resultList) == 0 { 200 return resultList, nil 201 } 202 if !anySuccess { 203 return resultList, firstErr 204 } 205 return resultList, nil 206 } 207 208 // if skip is true the other return values are nil 209 func getMetadataInnerSingle(ctx context.Context, g *libkb.GlobalContext, 210 folder *keybase1.FolderHandle, responseRepo ServerResponseRepo) (info *keybase1.GitRepoInfo, skip bool, err error) { 211 212 cryptoer := NewCrypto(g) 213 214 // If the folder was passed in, use it. Otherwise, load the team to 215 // figure it out. 216 var repoFolder keybase1.FolderHandle 217 if folder != nil { 218 repoFolder = *folder 219 } else { 220 repoFolder, err = folderFromTeamID(ctx, g, responseRepo.TeamID, responseRepo.IsImplicit) 221 if err != nil { 222 return nil, false, err 223 } 224 225 // Currently we want to pretend that multi-user personal repos 226 // (/keybase/{private,public}/chris,max/...) don't exist. Short circuit here 227 // to keep those out of the results list. 228 if repoFolder.Name != g.Env.GetUsername().String() && 229 (repoFolder.FolderType == keybase1.FolderType_PRIVATE || repoFolder.FolderType == keybase1.FolderType_PUBLIC) { 230 231 return nil, true, nil 232 } 233 } 234 235 teamIDVis := keybase1.TeamIDWithVisibility{ 236 TeamID: responseRepo.TeamID, 237 } 238 if repoFolder.FolderType != keybase1.FolderType_PUBLIC { 239 teamIDVis.Visibility = keybase1.TLFVisibility_PRIVATE 240 } else { 241 teamIDVis.Visibility = keybase1.TLFVisibility_PUBLIC 242 } 243 244 ciphertext, err := base64.StdEncoding.DecodeString(responseRepo.EncryptedMetadata) 245 if err != nil { 246 return nil, false, err 247 } 248 249 nonceSlice, err := base64.StdEncoding.DecodeString(responseRepo.Nonce) 250 if err != nil { 251 return nil, false, err 252 } 253 if len(nonceSlice) != len(keybase1.BoxNonce{}) { 254 return nil, false, fmt.Errorf("expected a nonce of length %d, found %d", len(keybase1.BoxNonce{}), len(nonceSlice)) 255 } 256 var nonce keybase1.BoxNonce 257 copy(nonce[:], nonceSlice) 258 259 encryptedMetadata := keybase1.EncryptedGitMetadata{ 260 V: responseRepo.EncryptionVersion, 261 E: ciphertext, 262 N: nonce, 263 Gen: responseRepo.KeyGeneration, 264 } 265 msgpackPlaintext, err := cryptoer.Unbox(ctx, teamIDVis, &encryptedMetadata) 266 if err != nil { 267 return nil, false, fmt.Errorf("repo tid:%v visibility:%s: %v", teamIDVis.TeamID, teamIDVis.Visibility, err) 268 } 269 270 var localMetadataVersioned keybase1.GitLocalMetadataVersioned 271 mh := codec.MsgpackHandle{WriteExt: true} 272 dec := codec.NewDecoderBytes(msgpackPlaintext, &mh) 273 err = dec.Decode(&localMetadataVersioned) 274 if err != nil { 275 return nil, false, err 276 } 277 278 // Translate back from GitLocalMetadataVersioned to the decoupled type 279 // that we use for local RPC. 280 version, err := localMetadataVersioned.Version() 281 if err != nil { 282 return nil, false, err 283 } 284 var localMetadata keybase1.GitLocalMetadata 285 switch version { 286 case keybase1.GitLocalMetadataVersion_V1: 287 localMetadata = keybase1.GitLocalMetadata{ 288 RepoName: localMetadataVersioned.V1().RepoName, 289 } 290 default: 291 return nil, false, fmt.Errorf("unrecognized variant of GitLocalMetadataVersioned: %#v", version) 292 } 293 294 // Load UPAKs to get the last writer username and device name. 295 lastWriterUPAK, _, err := g.GetUPAKLoader().LoadV2(libkb.NewLoadUserArgWithContext(ctx, g). 296 WithUID(responseRepo.LastModifyingUID). 297 WithPublicKeyOptional()) 298 if err != nil { 299 return nil, false, err 300 } 301 var deviceName string 302 for _, upk := range append([]keybase1.UserPlusKeysV2{lastWriterUPAK.Current}, lastWriterUPAK.PastIncarnations...) { 303 for _, deviceKey := range upk.DeviceKeys { 304 if deviceKey.DeviceID.Eq(responseRepo.LastModifyingDeviceID) { 305 deviceName = deviceKey.DeviceDescription 306 break 307 } 308 } 309 } 310 if deviceName == "" { 311 return nil, false, fmt.Errorf("can't find device name for %s's device ID %s", lastWriterUPAK.Current.Username, responseRepo.LastModifyingDeviceID) 312 } 313 314 var settings *keybase1.GitTeamRepoSettings 315 if repoFolder.FolderType == keybase1.FolderType_TEAM { 316 pset, err := convertTeamRepoSettings(ctx, g, responseRepo.TeamID, responseRepo.ChatConvID, responseRepo.ChatDisabled) 317 if err != nil { 318 return nil, false, err 319 } 320 settings = &pset 321 } 322 323 return &keybase1.GitRepoInfo{ 324 Folder: repoFolder, 325 RepoID: responseRepo.RepoID, 326 RepoUrl: formatRepoURL(repoFolder, string(localMetadata.RepoName)), 327 GlobalUniqueID: formatUniqueRepoID(responseRepo.TeamID, responseRepo.RepoID), 328 CanDelete: responseRepo.Role.IsAdminOrAbove(), 329 LocalMetadata: localMetadata, 330 ServerMetadata: keybase1.GitServerMetadata{ 331 Ctime: keybase1.ToTime(responseRepo.CTime), 332 Mtime: keybase1.ToTime(responseRepo.MTime), 333 LastModifyingUsername: lastWriterUPAK.Current.Username, 334 LastModifyingDeviceID: responseRepo.LastModifyingDeviceID, 335 LastModifyingDeviceName: deviceName, 336 }, 337 TeamRepoSettings: settings, 338 }, false, nil 339 } 340 341 func GetMetadata(ctx context.Context, g *libkb.GlobalContext, folder keybase1.FolderHandle) ([]keybase1.GitRepoResult, error) { 342 return getMetadataInner(ctx, g, &folder) 343 } 344 345 func GetAllMetadata(ctx context.Context, g *libkb.GlobalContext) ([]keybase1.GitRepoResult, error) { 346 return getMetadataInner(ctx, g, nil) 347 }