mirror of
https://github.com/Walter-Sparrow/lunar-tear.git
synced 2026-07-02 05:43:41 +03:00
Multi platform support
This commit is contained in:
@@ -18,6 +18,26 @@ var loginFS embed.FS
|
|||||||
|
|
||||||
var loginTmpl = template.Must(template.ParseFS(loginFS, "login.html"))
|
var loginTmpl = template.Must(template.ParseFS(loginFS, "login.html"))
|
||||||
|
|
||||||
|
// oauthRedirectTmpl drives the fbconnect:// hand-off via a renderer-initiated
|
||||||
|
// navigation instead of a server-side 302. Android WebView does NOT invoke
|
||||||
|
// WebViewClient.shouldOverrideUrlLoading for 302 redirects from POST form
|
||||||
|
// submissions to non-http schemes (documented Chromium WebView limitation,
|
||||||
|
// Stack Overflow #6738328 / Google issuetracker #36918490). Returning a 200
|
||||||
|
// HTML page with both <meta http-equiv="refresh"> and window.location.replace()
|
||||||
|
// makes the cross-scheme navigation renderer-initiated, which DOES invoke
|
||||||
|
// shouldOverrideUrlLoading, so the FB SDK can extract access_token from the
|
||||||
|
// URL fragment and complete its login flow. html/template auto-escapes {{.}}
|
||||||
|
// correctly for the meta URL-attribute context and the JS string-literal
|
||||||
|
// context inside <script>.
|
||||||
|
var oauthRedirectTmpl = template.Must(template.New("oauthRedirect").Parse(
|
||||||
|
`<!doctype html><html><head><meta charset="utf-8">
|
||||||
|
<meta http-equiv="refresh" content="0;url={{.}}">
|
||||||
|
<script>window.location.replace({{.}});</script>
|
||||||
|
</head><body>
|
||||||
|
<noscript><a href="{{.}}">Continue</a></noscript>
|
||||||
|
</body></html>
|
||||||
|
`))
|
||||||
|
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
store *AuthStore
|
store *AuthStore
|
||||||
tok *TokenService
|
tok *TokenService
|
||||||
@@ -30,6 +50,7 @@ func NewHandlers(store *AuthStore, tok *TokenService) *Handlers {
|
|||||||
type loginPageData struct {
|
type loginPageData struct {
|
||||||
RedirectURI string
|
RedirectURI string
|
||||||
State string
|
State string
|
||||||
|
Scope string
|
||||||
Error string
|
Error string
|
||||||
Username string
|
Username string
|
||||||
}
|
}
|
||||||
@@ -77,6 +98,7 @@ func (h *Handlers) oauthGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
data := loginPageData{
|
data := loginPageData{
|
||||||
RedirectURI: r.URL.Query().Get("redirect_uri"),
|
RedirectURI: r.URL.Query().Get("redirect_uri"),
|
||||||
State: r.URL.Query().Get("state"),
|
State: r.URL.Query().Get("state"),
|
||||||
|
Scope: r.URL.Query().Get("scope"),
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if err := loginTmpl.Execute(w, data); err != nil {
|
if err := loginTmpl.Execute(w, data); err != nil {
|
||||||
@@ -95,11 +117,13 @@ func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
action := r.FormValue("action")
|
action := r.FormValue("action")
|
||||||
redirectURI := r.FormValue("redirect_uri")
|
redirectURI := r.FormValue("redirect_uri")
|
||||||
state := r.FormValue("state")
|
state := r.FormValue("state")
|
||||||
|
scope := r.FormValue("scope")
|
||||||
|
|
||||||
renderErr := func(msg string) {
|
renderErr := func(msg string) {
|
||||||
data := loginPageData{
|
data := loginPageData{
|
||||||
RedirectURI: redirectURI,
|
RedirectURI: redirectURI,
|
||||||
State: state,
|
State: state,
|
||||||
|
Scope: scope,
|
||||||
Error: msg,
|
Error: msg,
|
||||||
Username: username,
|
Username: username,
|
||||||
}
|
}
|
||||||
@@ -158,20 +182,31 @@ func (h *Handlers) oauthPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
payload := fmt.Sprintf(`{"user_id":"%d"}`, user.ID)
|
payload := fmt.Sprintf(`{"user_id":"%d"}`, user.ID)
|
||||||
b64 := base64.StdEncoding.EncodeToString([]byte(payload))
|
b64 := base64.RawURLEncoding.EncodeToString([]byte(payload))
|
||||||
|
|
||||||
fragment := url.Values{}
|
fragment := url.Values{}
|
||||||
fragment.Set("access_token", token)
|
fragment.Set("access_token", token)
|
||||||
fragment.Set("token_type", "bearer")
|
fragment.Set("token_type", "bearer")
|
||||||
fragment.Set("expires_in", strconv.FormatInt(int64(tokenTTL.Seconds()), 10))
|
fragment.Set("expires_in", strconv.FormatInt(int64(tokenTTL.Seconds()), 10))
|
||||||
fragment.Set("signed_request", "0."+b64)
|
fragment.Set("signed_request", "0."+b64)
|
||||||
|
// iOS FBSDKLoginManager treats an empty granted_scopes set as a cancelled login
|
||||||
|
// (LoginManager.swift -> getSuccessResult -> getCancelledResult). Echo back the
|
||||||
|
// scope the SDK sent so parameters.permissions is non-empty and the SDK fires
|
||||||
|
// its success path. Android tolerates either way.
|
||||||
|
if scope != "" {
|
||||||
|
fragment.Set("granted_scopes", scope)
|
||||||
|
fragment.Set("denied_scopes", "")
|
||||||
|
}
|
||||||
if state != "" {
|
if state != "" {
|
||||||
fragment.Set("state", state)
|
fragment.Set("state", state)
|
||||||
}
|
}
|
||||||
|
|
||||||
target := redirectURI + "?" + fragment.Encode()
|
target := redirectURI + "?" + fragment.Encode()
|
||||||
log.Printf("redirecting to %s", target)
|
log.Printf("redirecting to %s", target)
|
||||||
http.Redirect(w, r, target, http.StatusFound)
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := oauthRedirectTmpl.Execute(w, target); err != nil {
|
||||||
|
log.Printf("render oauth redirect: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) HandleCheckUsername(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) HandleCheckUsername(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -128,6 +128,7 @@
|
|||||||
|
|
||||||
<input type="hidden" name="redirect_uri" value="{{.RedirectURI}}">
|
<input type="hidden" name="redirect_uri" value="{{.RedirectURI}}">
|
||||||
<input type="hidden" name="state" value="{{.State}}">
|
<input type="hidden" name="state" value="{{.State}}">
|
||||||
|
<input type="hidden" name="scope" value="{{.Scope}}">
|
||||||
|
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" autofocus required>
|
<input type="text" id="username" name="username" value="{{.Username}}" autocomplete="username" autofocus required>
|
||||||
|
|||||||
+52
-11
@@ -122,23 +122,62 @@ type assetCheck struct {
|
|||||||
var requiredAssets = []assetCheck{
|
var requiredAssets = []assetCheck{
|
||||||
{"assets", true},
|
{"assets", true},
|
||||||
{"assets/release/20240404193219.bin.e", false},
|
{"assets/release/20240404193219.bin.e", false},
|
||||||
{"assets/revisions/0/list.bin", false},
|
}
|
||||||
{"assets/revisions/0/assetbundle", true},
|
|
||||||
{"assets/revisions/0/resources", true},
|
// platformAssets holds the per-platform rev-0 layout. At least one full set
|
||||||
|
// must be present; users may have only Android or only iOS extracted.
|
||||||
|
var platformAssets = [][]assetCheck{
|
||||||
|
{
|
||||||
|
{"assets/revisions/0/android/list.bin", false},
|
||||||
|
{"assets/revisions/0/android/assetbundle", true},
|
||||||
|
{"assets/revisions/0/android/resources", true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{"assets/revisions/0/ios/list.bin", false},
|
||||||
|
{"assets/revisions/0/ios/assetbundle", true},
|
||||||
|
{"assets/revisions/0/ios/resources", true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkAsset(a assetCheck) (missing string, ok bool) {
|
||||||
|
info, err := os.Stat(a.path)
|
||||||
|
if err != nil {
|
||||||
|
return a.path, false
|
||||||
|
}
|
||||||
|
if a.dir && !info.IsDir() {
|
||||||
|
return a.path + string(filepath.Separator), false
|
||||||
|
}
|
||||||
|
if !a.dir && info.IsDir() {
|
||||||
|
return a.path, false
|
||||||
|
}
|
||||||
|
return "", true
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAssets() {
|
func validateAssets() {
|
||||||
var missing []string
|
var missing []string
|
||||||
for _, a := range requiredAssets {
|
for _, a := range requiredAssets {
|
||||||
info, err := os.Stat(a.path)
|
if m, ok := checkAsset(a); !ok {
|
||||||
if err != nil {
|
missing = append(missing, m)
|
||||||
missing = append(missing, a.path)
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if a.dir && !info.IsDir() {
|
}
|
||||||
missing = append(missing, a.path+string(filepath.Separator))
|
|
||||||
} else if !a.dir && info.IsDir() {
|
var platformMissing [][]string
|
||||||
missing = append(missing, a.path)
|
anyPlatformOK := false
|
||||||
|
for _, group := range platformAssets {
|
||||||
|
var groupMissing []string
|
||||||
|
for _, a := range group {
|
||||||
|
if m, ok := checkAsset(a); !ok {
|
||||||
|
groupMissing = append(groupMissing, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(groupMissing) == 0 {
|
||||||
|
anyPlatformOK = true
|
||||||
|
}
|
||||||
|
platformMissing = append(platformMissing, groupMissing)
|
||||||
|
}
|
||||||
|
if !anyPlatformOK {
|
||||||
|
for _, gm := range platformMissing {
|
||||||
|
missing = append(missing, gm...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +200,8 @@ func validateAssets() {
|
|||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(dimStyle.Render(" Place the extracted game assets under server/assets/ and try again."))
|
b.WriteString(dimStyle.Render(" Place the extracted game assets under server/assets/ and try again."))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
b.WriteString(dimStyle.Render(" At least one of assets/revisions/0/android/ or assets/revisions/0/ios/ must be fully present."))
|
||||||
|
b.WriteString("\n")
|
||||||
b.WriteString(dimStyle.Render(" Get them from ") + hlStyle.Render("#resources") + dimStyle.Render(" on Discord: ") + hlStyle.Hyperlink("https://discord.com/invite/MZAf5aVkJG").Render("https://discord.com/invite/MZAf5aVkJG"))
|
b.WriteString(dimStyle.Render(" Get them from ") + hlStyle.Render("#resources") + dimStyle.Render(" on Discord: ") + hlStyle.Hyperlink("https://discord.com/invite/MZAf5aVkJG").Render("https://discord.com/invite/MZAf5aVkJG"))
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
|
|
||||||
|
|||||||
@@ -70,32 +70,32 @@ func (t *revisionTracker) Active(clientAddr string) string {
|
|||||||
return revision
|
return revision
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *assetResolver) Resolve(objectId, assetType, activeRevision string) (assetResolution, bool) {
|
func (r *assetResolver) Resolve(objectId, assetType, activeRevision, platform string) (assetResolution, bool) {
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
resolution := assetResolution{ActiveRevision: activeRevision}
|
resolution := assetResolution{ActiveRevision: activeRevision}
|
||||||
revision := activeRevision
|
revision := activeRevision
|
||||||
|
|
||||||
candidates, listSize, ok := objectIdToFilePathCandidates(r.baseDir, revision, assetType, objectId)
|
candidates, listSize, ok := objectIdToFilePathCandidates(r.baseDir, revision, platform, assetType, objectId)
|
||||||
if ok && len(candidates) > 0 {
|
if ok && len(candidates) > 0 {
|
||||||
resolution.ListRevision = revision
|
resolution.ListRevision = revision
|
||||||
resolution.ListSize = listSize
|
resolution.ListSize = listSize
|
||||||
resolution.Candidates = candidates
|
resolution.Candidates = candidates
|
||||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||||
log.Printf("[HTTP] Asset resolve slow: object_id=%s type=%s active_revision=%s list_revision=%s elapsed=%s", objectId, assetType, activeRevision, revision, elapsed)
|
log.Printf("[HTTP] Asset resolve slow: object_id=%s type=%s platform=%s active_revision=%s list_revision=%s elapsed=%s", objectId, assetType, platform, activeRevision, revision, elapsed)
|
||||||
}
|
}
|
||||||
return resolution, true
|
return resolution, true
|
||||||
}
|
}
|
||||||
|
|
||||||
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
|
||||||
log.Printf("[HTTP] Asset resolve miss: object_id=%s type=%s active_revision=%s elapsed=%s", objectId, assetType, activeRevision, elapsed)
|
log.Printf("[HTTP] Asset resolve miss: object_id=%s type=%s platform=%s active_revision=%s elapsed=%s", objectId, assetType, platform, activeRevision, elapsed)
|
||||||
}
|
}
|
||||||
return resolution, false
|
return resolution, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *assetResolver) Prewarm(activeRevision string) {
|
func (r *assetResolver) Prewarm(activeRevision, platform string) {
|
||||||
if activeRevision == "" {
|
if activeRevision == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = loadListBinIndex(r.baseDir, activeRevision)
|
_, _ = loadListBinIndex(r.baseDir, activeRevision, platform)
|
||||||
_ = loadInfoIndex(r.baseDir, activeRevision)
|
_ = loadInfoIndex(r.baseDir, activeRevision, platform)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,14 +45,21 @@ type infoLoad struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
listBinCache = make(map[string]listBinIndex) // revision → index
|
listBinCache = make(map[string]listBinIndex) // revision/platform → index
|
||||||
listBinInflight = make(map[string]*listBinLoad)
|
listBinInflight = make(map[string]*listBinLoad)
|
||||||
listBinCacheMu sync.RWMutex
|
listBinCacheMu sync.RWMutex
|
||||||
infoCache = make(map[string]map[string]infoAlias) // revision → from-name → duplicate target
|
infoCache = make(map[string]map[string]infoAlias) // revision/platform → from-name → duplicate target
|
||||||
infoInflight = make(map[string]*infoLoad)
|
infoInflight = make(map[string]*infoLoad)
|
||||||
infoCacheMu sync.RWMutex
|
infoCacheMu sync.RWMutex
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func cacheKey(revision, platform string) string {
|
||||||
|
if platform == "" {
|
||||||
|
return revision + "/_shared"
|
||||||
|
}
|
||||||
|
return revision + "/" + platform
|
||||||
|
}
|
||||||
|
|
||||||
// infoJSONEntry is one entry from assets/revisions/{rev}/info.json (duplicate files: serve to-name when asked for from-name).
|
// infoJSONEntry is one entry from assets/revisions/{rev}/info.json (duplicate files: serve to-name when asked for from-name).
|
||||||
type infoJSONEntry struct {
|
type infoJSONEntry struct {
|
||||||
FromName string `json:"from-name"`
|
FromName string `json:"from-name"`
|
||||||
@@ -208,33 +215,34 @@ func parseListBin(data []byte) listBinIndex {
|
|||||||
return idx
|
return idx
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadListBinIndex(baseDir, revision string) (listBinIndex, bool) {
|
func loadListBinIndex(baseDir, revision, platform string) (listBinIndex, bool) {
|
||||||
|
key := cacheKey(revision, platform)
|
||||||
listBinCacheMu.RLock()
|
listBinCacheMu.RLock()
|
||||||
idx, ok := listBinCache[revision]
|
idx, ok := listBinCache[key]
|
||||||
listBinCacheMu.RUnlock()
|
listBinCacheMu.RUnlock()
|
||||||
if ok {
|
if ok {
|
||||||
return idx, true
|
return idx, true
|
||||||
}
|
}
|
||||||
|
|
||||||
listBinCacheMu.Lock()
|
listBinCacheMu.Lock()
|
||||||
if idx, ok := listBinCache[revision]; ok {
|
if idx, ok := listBinCache[key]; ok {
|
||||||
listBinCacheMu.Unlock()
|
listBinCacheMu.Unlock()
|
||||||
return idx, true
|
return idx, true
|
||||||
}
|
}
|
||||||
if load := listBinInflight[revision]; load != nil {
|
if load := listBinInflight[key]; load != nil {
|
||||||
listBinCacheMu.Unlock()
|
listBinCacheMu.Unlock()
|
||||||
<-load.done
|
<-load.done
|
||||||
return load.idx, load.ok
|
return load.idx, load.ok
|
||||||
}
|
}
|
||||||
load := &listBinLoad{done: make(chan struct{})}
|
load := &listBinLoad{done: make(chan struct{})}
|
||||||
listBinInflight[revision] = load
|
listBinInflight[key] = load
|
||||||
listBinCacheMu.Unlock()
|
listBinCacheMu.Unlock()
|
||||||
|
|
||||||
filePath := filepath.Join(baseDir, "assets", "revisions", revision, "list.bin")
|
filePath := filepath.Join(baseDir, "assets", "revisions", revision, platform, "list.bin")
|
||||||
data, err := os.ReadFile(filePath)
|
data, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
listBinCacheMu.Lock()
|
listBinCacheMu.Lock()
|
||||||
delete(listBinInflight, revision)
|
delete(listBinInflight, key)
|
||||||
close(load.done)
|
close(load.done)
|
||||||
listBinCacheMu.Unlock()
|
listBinCacheMu.Unlock()
|
||||||
return nil, false
|
return nil, false
|
||||||
@@ -243,41 +251,42 @@ func loadListBinIndex(baseDir, revision string) (listBinIndex, bool) {
|
|||||||
load.idx = idx
|
load.idx = idx
|
||||||
load.ok = true
|
load.ok = true
|
||||||
listBinCacheMu.Lock()
|
listBinCacheMu.Lock()
|
||||||
listBinCache[revision] = idx
|
listBinCache[key] = idx
|
||||||
delete(listBinInflight, revision)
|
delete(listBinInflight, key)
|
||||||
close(load.done)
|
close(load.done)
|
||||||
listBinCacheMu.Unlock()
|
listBinCacheMu.Unlock()
|
||||||
return idx, true
|
return idx, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadInfoIndex(baseDir, revision string) map[string]infoAlias {
|
func loadInfoIndex(baseDir, revision, platform string) map[string]infoAlias {
|
||||||
|
key := cacheKey(revision, platform)
|
||||||
infoCacheMu.RLock()
|
infoCacheMu.RLock()
|
||||||
m, ok := infoCache[revision]
|
m, ok := infoCache[key]
|
||||||
infoCacheMu.RUnlock()
|
infoCacheMu.RUnlock()
|
||||||
if ok {
|
if ok {
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
infoCacheMu.Lock()
|
infoCacheMu.Lock()
|
||||||
if m, ok := infoCache[revision]; ok {
|
if m, ok := infoCache[key]; ok {
|
||||||
infoCacheMu.Unlock()
|
infoCacheMu.Unlock()
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
if load := infoInflight[revision]; load != nil {
|
if load := infoInflight[key]; load != nil {
|
||||||
infoCacheMu.Unlock()
|
infoCacheMu.Unlock()
|
||||||
<-load.done
|
<-load.done
|
||||||
return load.m
|
return load.m
|
||||||
}
|
}
|
||||||
load := &infoLoad{done: make(chan struct{})}
|
load := &infoLoad{done: make(chan struct{})}
|
||||||
infoInflight[revision] = load
|
infoInflight[key] = load
|
||||||
infoCacheMu.Unlock()
|
infoCacheMu.Unlock()
|
||||||
|
|
||||||
filePath := filepath.Join(baseDir, "assets", "revisions", revision, "info.json")
|
filePath := filepath.Join(baseDir, "assets", "revisions", revision, platform, "info.json")
|
||||||
data, err := os.ReadFile(filePath)
|
data, err := os.ReadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
infoCacheMu.Lock()
|
infoCacheMu.Lock()
|
||||||
infoCache[revision] = nil
|
infoCache[key] = nil
|
||||||
delete(infoInflight, revision)
|
delete(infoInflight, key)
|
||||||
close(load.done)
|
close(load.done)
|
||||||
infoCacheMu.Unlock()
|
infoCacheMu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
@@ -285,8 +294,8 @@ func loadInfoIndex(baseDir, revision string) map[string]infoAlias {
|
|||||||
var entries []infoJSONEntry
|
var entries []infoJSONEntry
|
||||||
if err := json.Unmarshal(data, &entries); err != nil {
|
if err := json.Unmarshal(data, &entries); err != nil {
|
||||||
infoCacheMu.Lock()
|
infoCacheMu.Lock()
|
||||||
infoCache[revision] = nil
|
infoCache[key] = nil
|
||||||
delete(infoInflight, revision)
|
delete(infoInflight, key)
|
||||||
close(load.done)
|
close(load.done)
|
||||||
infoCacheMu.Unlock()
|
infoCacheMu.Unlock()
|
||||||
return nil
|
return nil
|
||||||
@@ -307,8 +316,8 @@ func loadInfoIndex(baseDir, revision string) map[string]infoAlias {
|
|||||||
}
|
}
|
||||||
load.m = m
|
load.m = m
|
||||||
infoCacheMu.Lock()
|
infoCacheMu.Lock()
|
||||||
infoCache[revision] = m
|
infoCache[key] = m
|
||||||
delete(infoInflight, revision)
|
delete(infoInflight, key)
|
||||||
close(load.done)
|
close(load.done)
|
||||||
infoCacheMu.Unlock()
|
infoCacheMu.Unlock()
|
||||||
return m
|
return m
|
||||||
@@ -378,7 +387,7 @@ func hasNonASCII(s string) bool {
|
|||||||
// an en locale fallback is appended (marked IsLocaleFallback so callers can skip MD5 validation).
|
// an en locale fallback is appended (marked IsLocaleFallback so callers can skip MD5 validation).
|
||||||
// For paths with non-ASCII characters, mojibake (double-encoded) and fullwidth-to-ASCII
|
// For paths with non-ASCII characters, mojibake (double-encoded) and fullwidth-to-ASCII
|
||||||
// variants are also tried.
|
// variants are also tried.
|
||||||
func pathStrToFullPaths(baseDir, revision, assetType, pathStr string) []pathCandidate {
|
func pathStrToFullPaths(baseDir, revision, platform, assetType, pathStr string) []pathCandidate {
|
||||||
fsPath := strings.ReplaceAll(pathStr, ")", "/")
|
fsPath := strings.ReplaceAll(pathStr, ")", "/")
|
||||||
if strings.Contains(fsPath, "..") || filepath.IsAbs(fsPath) || strings.HasPrefix(fsPath, "/") {
|
if strings.Contains(fsPath, "..") || filepath.IsAbs(fsPath) || strings.HasPrefix(fsPath, "/") {
|
||||||
return nil
|
return nil
|
||||||
@@ -402,7 +411,7 @@ func pathStrToFullPaths(baseDir, revision, assetType, pathStr string) []pathCand
|
|||||||
if strings.Contains(pathStr, ")ko)") {
|
if strings.Contains(pathStr, ")ko)") {
|
||||||
entries = append(entries, tagged{strings.ReplaceAll(pathStr, ")ko)", ")en)"), true})
|
entries = append(entries, tagged{strings.ReplaceAll(pathStr, ")ko)", ")en)"), true})
|
||||||
}
|
}
|
||||||
base := filepath.Join(baseDir, "assets", "revisions", revision)
|
base := filepath.Join(baseDir, "assets", "revisions", revision, platform)
|
||||||
var out []pathCandidate
|
var out []pathCandidate
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
@@ -434,34 +443,43 @@ func appendUniqueCandidate(candidates []assetCandidate, seen map[string]bool, ca
|
|||||||
return append(candidates, candidate)
|
return append(candidates, candidate)
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateCandidatePath(baseDir string, candidate assetCandidate, assetType, targetRevision, targetBaseName string) string {
|
func duplicateCandidatePath(baseDir string, candidate assetCandidate, platform, assetType, targetRevision, targetBaseName string) string {
|
||||||
root := filepath.Join(baseDir, "assets", "revisions", candidate.Revision, assetType)
|
root := filepath.Join(baseDir, "assets", "revisions", candidate.Revision, platform, assetType)
|
||||||
rel, err := filepath.Rel(root, candidate.Path)
|
rel, err := filepath.Rel(root, candidate.Path)
|
||||||
if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
if err != nil || strings.HasPrefix(rel, "..") || filepath.IsAbs(rel) {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return filepath.Join(baseDir, "assets", "revisions", targetRevision, assetType, filepath.Dir(rel), targetBaseName)
|
return filepath.Join(baseDir, "assets", "revisions", targetRevision, platform, assetType, filepath.Dir(rel), targetBaseName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// objectIdToFilePathCandidates returns candidate file paths for the object: list.bin path, locale fallbacks
|
// objectIdToFilePathCandidates returns candidate file paths for the object: list.bin path, locale fallbacks
|
||||||
// (ja/ko -> en), then info.json duplicate mappings (from-name -> to-name, possibly in a different revision).
|
// (ja/ko -> en), then info.json duplicate mappings (from-name -> to-name, possibly in a different revision).
|
||||||
// The original locale path is tried first (with MD5 validation); locale fallbacks are tried after
|
// The original locale path is tried first (with MD5 validation); locale fallbacks are tried after
|
||||||
// (without MD5 validation, since the hash in list.bin refers to the original locale's content).
|
// (without MD5 validation, since the hash in list.bin refers to the original locale's content).
|
||||||
|
//
|
||||||
|
// Two tiers are searched in order: the requested platform tree (e.g. revisions/0/ios/...) and then
|
||||||
|
// the un-split shared tree (revisions/0/...) which acts as a fallback for operators who deploy a
|
||||||
|
// single unified asset dump. Each tier carries its own list.bin md5 so corruption is still detected.
|
||||||
// Callers should try each path until one exists on disk.
|
// Callers should try each path until one exists on disk.
|
||||||
func objectIdToFilePathCandidates(baseDir, revision, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) {
|
func objectIdToFilePathCandidates(baseDir, revision, platform, assetType, objectId string) (candidates []assetCandidate, size int64, ok bool) {
|
||||||
idx, ok := loadListBinIndex(baseDir, revision)
|
|
||||||
if !ok || idx == nil {
|
|
||||||
return nil, 0, false
|
|
||||||
}
|
|
||||||
entry, ok := idx[objectId]
|
|
||||||
if !ok || entry.Path == "" {
|
|
||||||
return nil, 0, false
|
|
||||||
}
|
|
||||||
paths := pathStrToFullPaths(baseDir, revision, assetType, entry.Path)
|
|
||||||
if len(paths) == 0 {
|
|
||||||
return nil, 0, false
|
|
||||||
}
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
|
var firstSize int64
|
||||||
|
var anyHit bool
|
||||||
|
|
||||||
|
appendForPlatform := func(p, label string) {
|
||||||
|
idx, idxOk := loadListBinIndex(baseDir, revision, p)
|
||||||
|
if !idxOk || idx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry, entryOk := idx[objectId]
|
||||||
|
if !entryOk || entry.Path == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
paths := pathStrToFullPaths(baseDir, revision, p, assetType, entry.Path)
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tierStart := len(candidates)
|
||||||
for _, pc := range paths {
|
for _, pc := range paths {
|
||||||
md5 := entry.MD5
|
md5 := entry.MD5
|
||||||
if pc.IsLocaleFallback {
|
if pc.IsLocaleFallback {
|
||||||
@@ -470,28 +488,41 @@ func objectIdToFilePathCandidates(baseDir, revision, assetType, objectId string)
|
|||||||
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
|
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
|
||||||
Path: pc.Path,
|
Path: pc.Path,
|
||||||
Revision: revision,
|
Revision: revision,
|
||||||
Source: "list.bin",
|
Source: "list.bin (" + label + ")",
|
||||||
ExpectedMD5: md5,
|
ExpectedMD5: md5,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
infoIndex := loadInfoIndex(baseDir, revision)
|
infoIndex := loadInfoIndex(baseDir, revision, p)
|
||||||
if len(infoIndex) > 0 {
|
if len(infoIndex) > 0 {
|
||||||
for _, c := range candidates {
|
tierCandidates := candidates[tierStart:]
|
||||||
alias, ok := infoIndex[filepath.Base(c.Path)]
|
for _, c := range tierCandidates {
|
||||||
if !ok || alias.ToName == "" {
|
alias, aliasOk := infoIndex[filepath.Base(c.Path)]
|
||||||
|
if !aliasOk || alias.ToName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
alt := duplicateCandidatePath(baseDir, c, assetType, alias.ToRevision, alias.ToName)
|
alt := duplicateCandidatePath(baseDir, c, p, assetType, alias.ToRevision, alias.ToName)
|
||||||
if alt == "" {
|
if alt == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
|
candidates = appendUniqueCandidate(candidates, seen, assetCandidate{
|
||||||
Path: alt,
|
Path: alt,
|
||||||
Revision: alias.ToRevision,
|
Revision: alias.ToRevision,
|
||||||
Source: "info.json redirect",
|
Source: "info.json redirect (" + label + ")",
|
||||||
ExpectedMD5: alias.MD5,
|
ExpectedMD5: alias.MD5,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return candidates, entry.Size, true
|
if !anyHit {
|
||||||
|
firstSize = entry.Size
|
||||||
|
anyHit = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
appendForPlatform(platform, platform)
|
||||||
|
appendForPlatform("", "shared")
|
||||||
|
|
||||||
|
if !anyHit {
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
return candidates, firstSize, true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,9 @@ func NewOctoHTTPServer(resourcesBaseURL, baseDir string) *OctoHTTPServer {
|
|||||||
revisions: newRevisionTracker(),
|
revisions: newRevisionTracker(),
|
||||||
resolver: newAssetResolver(baseDir),
|
resolver: newAssetResolver(baseDir),
|
||||||
}
|
}
|
||||||
s.resolver.Prewarm("0")
|
s.resolver.Prewarm("0", platformAndroid)
|
||||||
|
s.resolver.Prewarm("0", platformIOS)
|
||||||
|
s.resolver.Prewarm("0", "")
|
||||||
s.mux.HandleFunc("/", s.handleAll)
|
s.mux.HandleFunc("/", s.handleAll)
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -142,12 +144,24 @@ func (s *OctoHTTPServer) Handler() http.Handler {
|
|||||||
return s.mux
|
return s.mux
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listBinPath prefers the platform-split list.bin and falls back to the un-split shared tree
|
||||||
|
// when the platform-specific file is missing, so operators with a single unified asset dump
|
||||||
|
// keep working.
|
||||||
|
func (s *OctoHTTPServer) listBinPath(revision, platform string) string {
|
||||||
|
p := filepath.Join(s.BaseDir, "assets", "revisions", revision, platform, "list.bin")
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return filepath.Join(s.BaseDir, "assets", "revisions", revision, "list.bin")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
|
func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
|
||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
|
platform := platformFromUserAgent(r)
|
||||||
isAssetRequest := strings.Contains(path, "/unso-")
|
isAssetRequest := strings.Contains(path, "/unso-")
|
||||||
isMasterDataRequest := strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin")
|
isMasterDataRequest := strings.Contains(path, "/assets/release/") && strings.Contains(path, "database.bin")
|
||||||
if !isAssetRequest && !isMasterDataRequest {
|
if !isAssetRequest && !isMasterDataRequest {
|
||||||
log.Printf("[HTTP] %s %s (Host: %s)", r.Method, r.URL.String(), r.Host)
|
log.Printf("[HTTP] %s %s (Host: %s, platform: %s)", r.Method, r.URL.String(), r.Host, platform)
|
||||||
for k, v := range r.Header {
|
for k, v := range r.Header {
|
||||||
log.Printf("[HTTP] %s: %s", k, v)
|
log.Printf("[HTTP] %s: %s", k, v)
|
||||||
}
|
}
|
||||||
@@ -155,13 +169,13 @@ func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Octo v2 API — asset bundle management
|
// Octo v2 API — asset bundle management
|
||||||
if strings.HasPrefix(path, "/v2/") {
|
if strings.HasPrefix(path, "/v2/") {
|
||||||
s.handleOctoV2(w, r, path)
|
s.handleOctoV2(w, r, path, platform)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Octo v1 list: /v1/list/{version}/{revision} — same list.bin as v2, keyed by revision
|
// Octo v1 list: /v1/list/{version}/{revision} — same list.bin as v2, keyed by revision
|
||||||
if strings.HasPrefix(path, "/v1/list/") {
|
if strings.HasPrefix(path, "/v1/list/") {
|
||||||
s.serveOctoV1List(w, r, path)
|
s.serveOctoV1List(w, r, path, platform)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +202,7 @@ func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// Asset bundle requests (from list.bin URLs: .../unso-{v}-{type}/{o}?generation=...&alt=media)
|
// Asset bundle requests (from list.bin URLs: .../unso-{v}-{type}/{o}?generation=...&alt=media)
|
||||||
if strings.Contains(path, "/unso-") {
|
if strings.Contains(path, "/unso-") {
|
||||||
s.serveUnsoAsset(w, r, path)
|
s.serveUnsoAsset(w, r, path, platform)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,8 +232,8 @@ func (s *OctoHTTPServer) handleAll(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write([]byte{})
|
w.Write([]byte{})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path string) {
|
func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, path, platform string) {
|
||||||
log.Printf("[OctoV2] %s %s", r.Method, path)
|
log.Printf("[OctoV2] %s %s (platform=%s)", r.Method, path, platform)
|
||||||
|
|
||||||
// /v2/pub/a/{appId}/v/{version}/list/{offset} — resource listing
|
// /v2/pub/a/{appId}/v/{version}/list/{offset} — resource listing
|
||||||
if strings.Contains(path, "/list/") {
|
if strings.Contains(path, "/list/") {
|
||||||
@@ -228,13 +242,13 @@ func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, pa
|
|||||||
requestedRevision := parts[len(parts)-1]
|
requestedRevision := parts[len(parts)-1]
|
||||||
if requestedRevision != "" {
|
if requestedRevision != "" {
|
||||||
revision := "0"
|
revision := "0"
|
||||||
filePath := filepath.Join(s.BaseDir, "assets", "revisions", "0", "list.bin")
|
filePath := s.listBinPath(revision, platform)
|
||||||
if requestedRevision != revision {
|
if requestedRevision != revision {
|
||||||
log.Printf("[OctoV2] Resource list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
|
log.Printf("[OctoV2] Resource list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
|
||||||
}
|
}
|
||||||
log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s)", filePath, requestedRevision, revision)
|
log.Printf("[OctoV2] Resource list request — serving %s (requested_revision=%s canonical_revision=%s platform=%s)", filePath, requestedRevision, revision, platform)
|
||||||
s.revisions.Remember(r.RemoteAddr, revision)
|
s.revisions.Remember(r.RemoteAddr, revision)
|
||||||
go s.resolver.Prewarm(revision)
|
go s.resolver.Prewarm(revision, platform)
|
||||||
s.serveListBin(w, filePath)
|
s.serveListBin(w, filePath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -259,8 +273,8 @@ func (s *OctoHTTPServer) handleOctoV2(w http.ResponseWriter, r *http.Request, pa
|
|||||||
w.WriteHeader(200)
|
w.WriteHeader(200)
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/list.bin.
|
// serveOctoV1List handles GET /v1/list/{version}/{revision} — serves assets/revisions/{revision}/{platform}/list.bin.
|
||||||
func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path string) {
|
func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request, path, platform string) {
|
||||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
// ["v1", "list", "300116832", "0"] -> revision = last segment
|
// ["v1", "list", "300116832", "0"] -> revision = last segment
|
||||||
requestedRevision := "0"
|
requestedRevision := "0"
|
||||||
@@ -268,18 +282,18 @@ func (s *OctoHTTPServer) serveOctoV1List(w http.ResponseWriter, r *http.Request,
|
|||||||
requestedRevision = parts[len(parts)-1]
|
requestedRevision = parts[len(parts)-1]
|
||||||
}
|
}
|
||||||
revision := "0"
|
revision := "0"
|
||||||
filePath := filepath.Join(s.BaseDir, "assets", "revisions", "0", "list.bin")
|
filePath := s.listBinPath(revision, platform)
|
||||||
if requestedRevision != revision {
|
if requestedRevision != revision {
|
||||||
log.Printf("[OctoV1] list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
|
log.Printf("[OctoV1] list request revision=%s canonicalized to revision=%s", requestedRevision, revision)
|
||||||
}
|
}
|
||||||
log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s)", r.Method, path, filePath, requestedRevision, revision)
|
log.Printf("[OctoV1] %s %s — serving %s (requested_revision=%s canonical_revision=%s platform=%s)", r.Method, path, filePath, requestedRevision, revision, platform)
|
||||||
s.revisions.Remember(r.RemoteAddr, revision)
|
s.revisions.Remember(r.RemoteAddr, revision)
|
||||||
go s.resolver.Prewarm(revision)
|
go s.resolver.Prewarm(revision, platform)
|
||||||
s.serveListBin(w, filePath)
|
s.serveListBin(w, filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// serveUnsoAsset serves asset bundle or resource for URLs like /resource-bundle-server/unso-{version}-{type}/{object_id}.
|
// serveUnsoAsset serves asset bundle or resource for URLs like /resource-bundle-server/unso-{version}-{type}/{object_id}.
|
||||||
func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path string) {
|
func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request, path, platform string) {
|
||||||
parts := strings.Split(strings.Trim(path, "/"), "/")
|
parts := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
var segment, objectId string
|
var segment, objectId string
|
||||||
for i, p := range parts {
|
for i, p := range parts {
|
||||||
@@ -311,9 +325,9 @@ func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
activeRevision := s.revisions.Active(r.RemoteAddr)
|
activeRevision := s.revisions.Active(r.RemoteAddr)
|
||||||
resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision)
|
resolution, ok := s.resolver.Resolve(objectId, assetType, activeRevision, platform)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s) no candidates", path, objectId, assetType, activeRevision)
|
log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s platform=%s active_revision=%s) no candidates", path, objectId, assetType, platform, activeRevision)
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
return
|
return
|
||||||
@@ -354,7 +368,7 @@ func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request,
|
|||||||
}
|
}
|
||||||
if !strings.EqualFold(actualMD5, candidate.ExpectedMD5) {
|
if !strings.EqualFold(actualMD5, candidate.ExpectedMD5) {
|
||||||
md5Mismatches = append(md5Mismatches, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"] expected="+candidate.ExpectedMD5+" actual="+actualMD5)
|
md5Mismatches = append(md5Mismatches, candidate.Revision+":"+candidate.Path+" ["+candidate.Source+"] expected="+candidate.ExpectedMD5+" actual="+actualMD5)
|
||||||
log.Printf("[HTTP] Asset md5 mismatch: object_id=%s type=%s path=%s expected=%s actual=%s active_revision=%s list_revision=%s resolved_revision=%s source=%s", objectId, assetType, candidate.Path, candidate.ExpectedMD5, actualMD5, resolution.ActiveRevision, resolution.ListRevision, candidate.Revision, candidate.Source)
|
log.Printf("[HTTP] Asset md5 mismatch: object_id=%s type=%s platform=%s path=%s expected=%s actual=%s active_revision=%s list_revision=%s resolved_revision=%s source=%s", objectId, assetType, platform, candidate.Path, candidate.ExpectedMD5, actualMD5, resolution.ActiveRevision, resolution.ListRevision, candidate.Revision, candidate.Source)
|
||||||
f.Close()
|
f.Close()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -366,9 +380,9 @@ func (s *OctoHTTPServer) serveUnsoAsset(w http.ResponseWriter, r *http.Request,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(md5Mismatches) > 0 {
|
if len(md5Mismatches) > 0 {
|
||||||
log.Printf("[HTTP] Asset md5 mismatches: object_id=%s type=%s active_revision=%s list_revision=%s mismatches=%v", objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, md5Mismatches)
|
log.Printf("[HTTP] Asset md5 mismatches: object_id=%s type=%s platform=%s active_revision=%s list_revision=%s mismatches=%v", objectId, assetType, platform, resolution.ActiveRevision, resolution.ListRevision, md5Mismatches)
|
||||||
}
|
}
|
||||||
log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s active_revision=%s list_revision=%s) tried paths: %v", path, objectId, assetType, resolution.ActiveRevision, resolution.ListRevision, triedPaths)
|
log.Printf("[HTTP] Asset not found: %s (object_id=%s type=%s platform=%s active_revision=%s list_revision=%s) tried paths: %v", path, objectId, assetType, platform, resolution.ActiveRevision, resolution.ListRevision, triedPaths)
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
w.Header().Set("Content-Type", "application/octet-stream")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
platformAndroid = "android"
|
||||||
|
platformIOS = "ios"
|
||||||
|
)
|
||||||
|
|
||||||
|
// platformFromUserAgent classifies an HTTP request as iOS vs Android based on
|
||||||
|
// the User-Agent header. Unity's UnityWebRequest does not set a UA on iOS, so
|
||||||
|
// CFNetwork's default ("<bundle>/<build> CFNetwork/x Darwin/x") is what arrives;
|
||||||
|
// on Android Unity sets "UnityPlayer/... (UnityWebRequest/...)". Any other UA
|
||||||
|
// (or none) is treated as Android, matching model.DefaultPlatform.
|
||||||
|
func platformFromUserAgent(r *http.Request) string {
|
||||||
|
ua := r.Header.Get("User-Agent")
|
||||||
|
if strings.Contains(ua, "Darwin/") || strings.Contains(ua, "CFNetwork/") {
|
||||||
|
return platformIOS
|
||||||
|
}
|
||||||
|
return platformAndroid
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user