remote-login.mjs 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. /** Native remote login: username/password against a yaoyao Web server.
  2. * Mirrors the browser's CSRF/Origin dance so no server changes are needed,
  3. * and can enroll this Mac as a computer with the logged-in session. */
  4. export function normalizeServerURL(input){
  5. const raw=String(input??'').trim()
  6. if(!raw)throw new Error('请填写夭夭服务器地址')
  7. const withScheme=/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)?raw:`http://${raw}`
  8. let url
  9. try{url=new URL(withScheme)}catch{throw new Error('服务器地址无效')}
  10. if(!['http:','https:'].includes(url.protocol)||url.username||url.password||url.search||url.hash||(url.pathname!==''&&url.pathname!=='/'))throw new Error('服务器地址无效')
  11. return url.origin
  12. }
  13. async function requestServer(fetchImpl,url,options){
  14. try{return await fetchImpl(url,options)}
  15. catch(error){
  16. if(['TimeoutError','AbortError'].includes(error.name))throw new Error('连接超时,请检查服务器地址和网络后重试。')
  17. throw new Error('无法连接服务器,请检查服务器地址和网络后重试。')
  18. }
  19. }
  20. /** Minimal cookie jar across the bootstrap → login dance. */
  21. class CookieJar{
  22. constructor(){this.cookies=new Map();this.details=new Map()}
  23. absorb(response){
  24. // Electron net.fetch can fold multiple cookies into one getSetCookie entry.
  25. // Split only before a cookie name, preserving commas inside Expires dates.
  26. const lines=(response.headers.getSetCookie?.()??[]).flatMap(value=>value.split(/,(?=\s*[^;,\s]+=)/g))
  27. for(const line of lines){
  28. const pair=line.split(';')[0],eq=pair.indexOf('=')
  29. if(eq<=0)continue
  30. const name=pair.slice(0,eq).trim(),value=pair.slice(eq+1).trim()
  31. this.cookies.set(name,value)
  32. const attributes=new Map(line.split(';').slice(1).map(part=>{
  33. const index=part.indexOf('=')
  34. return index<0?[part.trim().toLowerCase(),'']:[part.slice(0,index).trim().toLowerCase(),part.slice(index+1).trim()]
  35. }))
  36. const detail={name,value,path:attributes.get('path')||'/',httpOnly:attributes.has('httponly'),secure:attributes.has('secure')}
  37. const sameSite={strict:'strict',lax:'lax',none:'no_restriction'}[attributes.get('samesite')?.toLowerCase()]
  38. if(sameSite)detail.sameSite=sameSite
  39. const maxAge=attributes.get('max-age'),expires=Date.parse(attributes.get('expires'))
  40. if(maxAge!==undefined&&/^-?\d+$/.test(maxAge))detail.expirationDate=Date.now()/1000+Number(maxAge)
  41. else if(Number.isFinite(expires))detail.expirationDate=expires/1000
  42. this.details.set(name,detail)
  43. }
  44. }
  45. header(){return [...this.cookies.entries()].map(([name,value])=>`${name}=${value}`).join('; ')}
  46. }
  47. export async function inspectServer(serverURL,fetchImpl=globalThis.fetch){
  48. const base=normalizeServerURL(serverURL)
  49. const response=await requestServer(fetchImpl,new URL('/api/app/bootstrap?inspectOnly=1',base),{
  50. headers:{accept:'application/json'},redirect:'error',signal:AbortSignal.timeout(10000),
  51. })
  52. if(!response.ok)throw new Error(`无法连接服务器(HTTP ${response.status})`)
  53. const info=await response.json().catch(()=>{throw new Error('服务器响应无效,请确认这是夭夭服务器地址')})
  54. if(typeof info?.csrfToken!=='string'||typeof info?.authenticated!=='boolean'||(info.serverKind&&info.serverKind!=='yaoyao-web'))
  55. throw new Error('服务器响应无效,请确认这是夭夭服务器地址')
  56. return info
  57. }
  58. export function sessionCookieDetails(setCookies) {
  59. const jar = new CookieJar()
  60. jar.absorb({ headers: { getSetCookie: () => Array.isArray(setCookies) ? setCookies : [setCookies].filter(Boolean) } })
  61. return [...jar.details.values()]
  62. }
  63. export async function persistRemoteSession(serverURL, info, cookies, fetchImpl=globalThis.fetch) {
  64. const base = normalizeServerURL(serverURL)
  65. const response = await requestServer(fetchImpl, new URL('/api/app/desktop-session', base), {
  66. method: 'POST', redirect: 'error', signal: AbortSignal.timeout(10000),
  67. headers: { 'content-type': 'application/json', origin: base, 'x-csrf-token': info.csrfToken,
  68. cookie: cookies.map(({name,value}) => `${name}=${value}`).join('; ') }, body: '{}',
  69. })
  70. // Older servers retain their original session lifetime until upgraded.
  71. if (response.status === 404) return cookies
  72. if (!response.ok) throw new Error('无法保存桌面登录授权,请重新登录')
  73. return sessionCookieDetails(response.headers.getSetCookie())
  74. }
  75. export async function remoteSession(serverURL,{username,password,setup=false},fetchImpl=globalThis.fetch){
  76. const base=normalizeServerURL(serverURL)
  77. const user=String(username??'').trim(),pass=String(password??'')
  78. if(!user||!pass)throw new Error('请填写用户名和密码')
  79. const jar=new CookieJar()
  80. const bootstrap=await requestServer(fetchImpl,new URL('/api/app/bootstrap',base),{headers:{accept:'application/json'},redirect:'error',signal:AbortSignal.timeout(10000)})
  81. jar.absorb(bootstrap)
  82. if(!bootstrap.ok)throw new Error(`无法连接服务器(HTTP ${bootstrap.status})`)
  83. const info=await bootstrap.json().catch(()=>{throw new Error('服务器响应无效,请确认这是夭夭 Web 地址')})
  84. if(typeof info?.csrfToken!=='string')throw new Error('服务器响应无效,请确认这是夭夭 Web 地址')
  85. const response=await requestServer(fetchImpl,new URL(setup?'/api/app/setup':'/api/app/login',base),{
  86. method:'POST',redirect:'error',signal:AbortSignal.timeout(15000),
  87. headers:{accept:'application/json','content-type':'application/json',cookie:jar.header(),'x-csrf-token':info.csrfToken,origin:base},
  88. body:JSON.stringify({username:user,password:pass,desktop:true}),
  89. })
  90. jar.absorb(response)
  91. if(!setup&&(response.status===401||response.status===403)) {
  92. const body=await response.json().catch(()=>({}))
  93. if(body.code==='account_pending_approval')throw new Error(body.error || '账号尚未开通,请等待管理员开通。')
  94. throw new Error('用户名或密码不正确')
  95. }
  96. if(setup&&!response.ok){const body=await response.json().catch(()=>({}));throw new Error(body.error?.message||body.error||`创建管理员失败(HTTP ${response.status})`)}
  97. if(!response.ok)throw new Error(`登录失败(HTTP ${response.status})`)
  98. const result=await response.json().catch(()=>{throw new Error('登录响应无效')})
  99. if(!result?.user?.id)throw new Error('登录响应无效')
  100. return {origin:base,user:result.user,csrfToken:String(result.csrfToken??info.csrfToken),cookies:[...jar.cookies.entries()],cookieDetails:[...jar.details.values()]}
  101. }
  102. export async function enrollDesktopHost(session,{name,installId,previousHostId},fetchImpl=globalThis.fetch){
  103. const cookie=session.cookies.map(([key,value])=>`${key}=${value}`).join('; ')
  104. const response=await requestServer(fetchImpl,new URL('/api/app/admin/desktop-hosts',session.origin),{
  105. method:'POST',redirect:'error',signal:AbortSignal.timeout(15000),
  106. headers:{accept:'application/json','content-type':'application/json',cookie,'x-csrf-token':session.csrfToken,origin:session.origin},
  107. body:JSON.stringify({name:String(name).slice(0,100),...(installId?{installId}:{}),...(previousHostId?{previousHostId}:{})}),
  108. })
  109. if(response.status===403)throw new Error('需要管理员账号,才能把这台电脑注册为可远程控制的电脑')
  110. if(!response.ok)throw new Error(`注册电脑失败(HTTP ${response.status})`)
  111. const result=await response.json()
  112. if(typeof result?.token!=='string'||!result?.host?.id)throw new Error('注册电脑响应无效')
  113. return result
  114. }
  115. /** Registration owns a temporary cookie jar and never activates a desktop session. */
  116. export async function remoteRegistration(serverURL,{username,password},fetchImpl=globalThis.fetch){
  117. const base=normalizeServerURL(serverURL),jar=new CookieJar()
  118. const bootstrap=await requestServer(fetchImpl,new URL('/api/app/bootstrap',base),{redirect:'error',signal:AbortSignal.timeout(10000)})
  119. jar.absorb(bootstrap)
  120. const info=await bootstrap.json().catch(()=>({}))
  121. if(!bootstrap.ok||info.registrationAvailable!==true||info.setupRequired||typeof info.csrfToken!=='string')throw new Error('当前服务器尚不支持子账号注册,请联系管理员初始化或升级。')
  122. const response=await requestServer(fetchImpl,new URL('/api/app/register',base),{
  123. method:'POST',redirect:'error',signal:AbortSignal.timeout(15000),
  124. headers:{'content-type':'application/json',cookie:jar.header(),'x-csrf-token':info.csrfToken,origin:base},
  125. body:JSON.stringify({username:String(username).trim(),password}),
  126. })
  127. const result=await response.json().catch(()=>({}))
  128. if(!response.ok)throw new Error(result.error || `注册失败(HTTP ${response.status})`)
  129. if(result.registrationStatus!=='pending')throw new Error('注册响应无效,请联系管理员确认账号状态。')
  130. }