mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-11-14 00:07:00 +01:00
271 lines
8.1 KiB
Go
271 lines
8.1 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tdurieux/anonymous_github/backend/internal/anonymize"
|
|
"github.com/tdurieux/anonymous_github/backend/internal/provider"
|
|
"github.com/tdurieux/anonymous_github/backend/internal/security"
|
|
)
|
|
|
|
func rawCachePath(r M, a provider.Access, entry provider.Entry) string {
|
|
identity, _ := json.Marshal([]any{"go-raw-v1", a.Ref.Provider, a.Ref.ID, a.Ref.Commit, entry.Revision, r["contentCacheRevision"]})
|
|
digest := sha256.Sum256(identity)
|
|
return contentPrefix(r) + "/" + hex.EncodeToString(digest[:]) + "/" + entry.Path
|
|
}
|
|
|
|
func contentRevisionFilter(r M) M {
|
|
filter := revisionFilter(r)
|
|
// Cache-presence bookkeeping changes statusDate without changing content.
|
|
// Status, generation, settings, grant and cache revisions fence publication.
|
|
delete(filter, "statusDate")
|
|
for _, field := range []string{"anonymizeDate", "contentCacheRevision"} {
|
|
if value, ok := r[field]; ok {
|
|
filter[field] = value
|
|
} else {
|
|
filter[field] = M{"$exists": false}
|
|
}
|
|
}
|
|
return filter
|
|
}
|
|
|
|
func (s *Server) activeContentCache(ctx context.Context, r M) error {
|
|
filter := contentRevisionFilter(r)
|
|
current, err := s.Store.one(ctx, collection("repo"), filter)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current == nil {
|
|
return fail("repository_changed", 409)
|
|
}
|
|
switch str(current["status"]) {
|
|
case "expiring", "expired", "removing", "removed", "archived":
|
|
return fail("repository_changed", 409)
|
|
}
|
|
marker := "__content/__retired/" + strings.TrimPrefix(contentPrefix(r), "__content/") + ".txt"
|
|
_, err = s.content.Size(ctx, str(r["repoId"]), marker)
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return fail("repository_changed", 409)
|
|
}
|
|
|
|
func (s *Server) cachedBlob(ctx context.Context, r M, p provider.Repositories, a provider.Access, entry provider.Entry) (result io.ReadCloser, resultErr error) {
|
|
// Archive originals are authoritative data, not an upstream cache.
|
|
if a.Ref.Provider == "archive" {
|
|
return s.openMeasuredSource(ctx, p, a, entry)
|
|
}
|
|
if _, err := security.RelativePath(entry.Path); err != nil {
|
|
return nil, fail("invalid_path", 400)
|
|
}
|
|
if s.sourceCache != nil {
|
|
return s.sharedSource(ctx, r, p, a, entry)
|
|
}
|
|
started := time.Now()
|
|
defer func() {
|
|
if resultErr != nil {
|
|
s.observeCache(ctx, "source_error", 0, time.Since(started))
|
|
}
|
|
}()
|
|
requestContext := ctx
|
|
ctx, cancel := context.WithTimeout(ctx, 120*time.Second)
|
|
// This deadline covers filling, not the caller's subsequent read.
|
|
defer cancel()
|
|
id, cachePath := str(r["repoId"]), rawCachePath(r, a, entry)
|
|
hit := func() bool {
|
|
size, err := s.content.Size(ctx, id, cachePath)
|
|
return err == nil && size >= entry.Size && size <= anonymize.MaxInput
|
|
}
|
|
open := func() (io.ReadCloser, error) {
|
|
if err := s.activeContentCache(ctx, r); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.authorizeMeasured(ctx, p, a); err != nil {
|
|
return nil, err
|
|
}
|
|
// Use the original request context for the returned S3 stream.
|
|
return s.content.Read(requestContext, id, cachePath)
|
|
}
|
|
if err := s.activeContentCache(ctx, r); err != nil {
|
|
return nil, err
|
|
}
|
|
if hit() {
|
|
result, resultErr = open()
|
|
if resultErr == nil {
|
|
s.observeCache(ctx, "legacy_source_hit", entry.Size, 0)
|
|
}
|
|
return result, resultErr
|
|
}
|
|
s.observeCache(ctx, "legacy_source_miss", entry.Size, 0)
|
|
if s.cacheFills.Add(1) > 64 {
|
|
s.cacheFills.Add(-1)
|
|
s.observeCache(ctx, "source_bypass", entry.Size, 0)
|
|
return nil, fail("cache_busy", 503)
|
|
}
|
|
defer s.cacheFills.Add(-1)
|
|
digest := sha256.Sum256([]byte(id + ":" + cachePath))
|
|
lock := "go:content-fill:" + hex.EncodeToString(digest[:])
|
|
token, err := security.RandomToken()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
waited := false
|
|
for {
|
|
if hit() {
|
|
return open()
|
|
}
|
|
acquired, lockErr := s.Store.Redis.SetNX(ctx, lock, token, 125*time.Second).Result()
|
|
// Coordination is optional, as in TypeScript. Atomic storage publication and
|
|
// generation checks remain mandatory when Redis is unavailable.
|
|
if lockErr != nil || acquired {
|
|
if acquired {
|
|
defer func() {
|
|
cleanup, stop := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Second)
|
|
defer stop()
|
|
s.Store.Redis.Eval(cleanup, `if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) end;return 0`, []string{lock}, token)
|
|
}()
|
|
}
|
|
break
|
|
}
|
|
if !waited {
|
|
s.observeCache(ctx, "source_fill_wait", 0, 0)
|
|
waited = true
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, fail("cache_busy", 503)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
}
|
|
// A prior leader may have published between the lookup and lock acquisition.
|
|
if hit() {
|
|
return open()
|
|
}
|
|
if err := s.activeContentCache(ctx, r); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.authorizeMeasured(ctx, p, a); err != nil {
|
|
return nil, err
|
|
}
|
|
body, err := s.openMeasuredSource(ctx, p, a, entry)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
limited := &cacheLimitReader{reader: body, remaining: anonymize.MaxInput}
|
|
err = s.content.Put(ctx, id, cachePath, limited, entry.Size, anonymize.MaxInput)
|
|
body.Close()
|
|
if err != nil {
|
|
if err.Error() == "storage_write_size_mismatch" {
|
|
return nil, fail("storage_write_size_mismatch", 502)
|
|
}
|
|
return nil, err
|
|
}
|
|
if err = s.activeContentCache(ctx, r); err != nil {
|
|
cleanup, stop := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
|
defer stop()
|
|
if removeErr := s.content.Remove(cleanup, id, cachePath); removeErr != nil {
|
|
return nil, removeErr
|
|
}
|
|
return nil, err
|
|
}
|
|
storedBytes := anonymize.MaxInput - limited.remaining
|
|
s.observeCache(ctx, "source_fill", storedBytes, time.Since(started))
|
|
s.observeCache(ctx, "source_stored", storedBytes, 0)
|
|
return open()
|
|
}
|
|
|
|
// Fence in-flight fills before deleting cached bytes. Archived storage is kept
|
|
// for the separate resumable archive cleanup workflow.
|
|
func (s *Server) resetContentCache(ctx context.Context, r M) error {
|
|
if s.pool != nil {
|
|
s.pool.RemoveResults(str(r["repoId"]))
|
|
}
|
|
if str(r["status"]) == "archived" {
|
|
return nil
|
|
}
|
|
entries, err := s.content.List(ctx, str(r["repoId"]))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
paths := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
paths = append(paths, entry.Path)
|
|
}
|
|
revision, err := security.RandomToken()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changed, err := s.Store.update(ctx, collection("repo"), revisionFilter(r), M{"$set": M{"contentCacheRevision": revision, "isReseted": true, "size": M{"storage": 0, "file": 0}}, "$unset": M{"sizeComputedAt": ""}})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !changed {
|
|
return fail("repository_changed", 409)
|
|
}
|
|
return s.content.RemoveFiles(ctx, str(r["repoId"]), paths)
|
|
}
|
|
|
|
// Read one byte beyond the limit to distinguish oversized content from EOF.
|
|
type cacheLimitReader struct {
|
|
reader io.Reader
|
|
remaining int64
|
|
}
|
|
|
|
func (r *cacheLimitReader) Read(p []byte) (int, error) {
|
|
if int64(len(p)) > r.remaining+1 {
|
|
p = p[:r.remaining+1]
|
|
}
|
|
n, err := r.reader.Read(p)
|
|
r.remaining -= int64(n)
|
|
if r.remaining < 0 {
|
|
return n, fail("file_too_big", 413)
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
// The caller owns r. Update its snapshot along with the database so subsequent
|
|
// file/ZIP publication checks observe this request's status timestamp.
|
|
func (s *Server) markCachePresent(ctx context.Context, r M) error {
|
|
if !boolean(r["isReseted"]) {
|
|
return nil
|
|
}
|
|
if str(r["status"]) != "ready" {
|
|
return fail("repository_changed", 409)
|
|
}
|
|
filter := contentRevisionFilter(r)
|
|
filter["isReseted"] = true
|
|
now := time.Now().Truncate(time.Millisecond)
|
|
changed, err := s.Store.update(ctx, collection("repo"), filter, M{"$set": M{"isReseted": false, "statusDate": now}, "$unset": M{"statusMessage": ""}})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !changed {
|
|
// Another successful read can have completed the same transition. Accept
|
|
// only that ready, non-reset state with the same source/settings/cache.
|
|
delete(filter, "statusDate")
|
|
filter["isReseted"] = false
|
|
current, err := s.Store.one(ctx, collection("repo"), filter)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current == nil {
|
|
return fail("repository_changed", 409)
|
|
}
|
|
now = date(current["statusDate"])
|
|
}
|
|
r["isReseted"], r["statusDate"] = false, now
|
|
delete(r, "statusMessage")
|
|
return nil
|
|
}
|