packages feed

hstratus-drive-0.1.0.0: src-internal/Network/HStratus/Internal/Drive/Download.hs

{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_HADDOCK prune #-}

{- |
Module      : Network.HStratus.Internal.Drive.Download
Copyright   : (c) 2026 Tim Emiola
Maintainer  : Tim Emiola <adetokunbo@emio.la>
SPDX-License-Identifier: BSD-3-Clause

API-call functions for reading and writing iCloud Drive nodes.
-}
module Network.HStratus.Internal.Drive.Download
  ( DriveError (..)
  , fetchNode
  , fetchChildren
  , fetchFile
  , execCreateFolder
  , execRenameNode
  , execDeleteNode
  , execUploadFile
  )
where

import Control.Exception (Exception, throwIO)
import Control.Monad (when)
import Data.Aeson (Value, eitherDecode, encode, object, (.=))
import Data.Aeson.Types (Parser, parseEither)
import qualified Data.ByteString.Lazy as LBS
import Data.Int (Int64)
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Time.Clock.POSIX (getPOSIXTime)
import Network.HStratus.Http (Api, HStratusError, rawRequest)
import Network.HStratus.Internal.Drive.Endpoints
  ( DriveEndpoints
  , commitUploadReq
  , createFolderBody
  , createFolderReq
  , deleteNodeBody
  , deleteNodeReq
  , downloadTokenReq
  , nodeDetailsBody
  , nodeDetailsReq
  , renameNodeBody
  , renameNodeReq
  , uploadTokenReq
  )
import Network.HStratus.Internal.Drive.Node
  ( DriveNode (..)
  , DriveNodeId (..)
  , FileData (..)
  , FolderData (..)
  , folderDocId
  , nodeEtag
  , nodeId
  )
import Network.HStratus.Internal.Drive.NodeData
  ( UploadReceipt (..)
  , parseChildrenResponse
  , parseDownloadUrl
  , parseNodeResponse
  , parseUploadReceiptResponse
  , parseUploadTokenResponse
  )
import Network.HTTP.Client
  ( Request
  , RequestBody (..)
  , Response (..)
  , parseRequest
  , requestBody
  , requestHeaders
  )
import Network.HTTP.Client.MultipartFormData (formDataBody, partContentType, partFilename, partLBS)
import Network.HTTP.Types (hContentType, statusCode)


-- | Errors that can occur during iCloud Drive API calls.
data DriveError
  = -- | the server returned an unexpected HTTP status code
    DriveHttpError Int
  | -- | a JSON response could not be decoded into the expected structure
    DriveParseError String
  | -- | the drive root node could not be resolved
    DriveInvalidRoot


instance Show DriveError where
  show (DriveHttpError n) = "iCloud Drive: HTTP error " <> show n
  show (DriveParseError msg) = "iCloud Drive: parse error: " <> msg
  show DriveInvalidRoot = "iCloud Drive: invalid root node"


instance Exception DriveError


instance HStratusError DriveError


-- | Fetch metadata for a single node.
fetchNode :: Api -> DriveEndpoints -> DriveNodeId -> IO DriveNode
fetchNode api ep nid = fetchWith "fetchNode" api (nodeReq ep nid) parseNodeResponse


-- | Fetch the immediate children of a folder.
fetchChildren :: Api -> DriveEndpoints -> DriveNodeId -> IO [DriveNode]
fetchChildren api ep nid = fetchWith "fetchChildren" api (nodeReq ep nid) parseChildrenResponse


-- | Download the contents of a file node as a lazy 'LBS.ByteString'.
fetchFile :: Api -> DriveEndpoints -> FileData -> IO LBS.ByteString
fetchFile api ep fd
  | maybe True (== 0) (fdSize fd) = pure LBS.empty
  | otherwise = do
      url <- fetchWith "fetchFile (token)" api (downloadTokenReq (fdDocId fd) (fdZone fd) ep) parseDownloadUrl
      contentReq <- getReqFromUrl url
      contentResp <- rawRequest api contentReq
      checkStatus contentResp
      pure $ responseBody contentResp


-- | Create a new folder under the given parent node.
execCreateFolder :: Api -> DriveEndpoints -> DriveNodeId -> Text -> IO ()
execCreateFolder api ep parentId name = do
  resp <- rawRequest api req
  checkStatus resp
 where
  req =
    (createFolderReq ep)
      { requestBody = RequestBodyLBS (createFolderBody ep parentId name)
      , requestHeaders = (hContentType, "application/json") : requestHeaders (createFolderReq ep)
      }


-- | Rename a drive node (folder or file).
execRenameNode :: Api -> DriveEndpoints -> DriveNode -> Text -> IO ()
execRenameNode api ep node name = do
  resp <- rawRequest api req
  checkStatus resp
 where
  req =
    (renameNodeReq ep)
      { requestBody = RequestBodyLBS (renameNodeBody (nodeId node) (nodeEtag node) name)
      , requestHeaders = (hContentType, "application/json") : requestHeaders (renameNodeReq ep)
      }


-- | Move a drive node (folder or file) to the trash.
execDeleteNode :: Api -> DriveEndpoints -> DriveNode -> IO ()
execDeleteNode api ep node = do
  resp <- rawRequest api req
  checkStatus resp
 where
  req =
    (deleteNodeReq ep)
      { requestBody = RequestBodyLBS (deleteNodeBody ep (nodeId node) (nodeEtag node))
      , requestHeaders = (hContentType, "application/json") : requestHeaders (deleteNodeReq ep)
      }


{- | Upload file content into a folder using the 3-step iCloud Drive upload
protocol.
-}
execUploadFile :: Api -> DriveEndpoints -> FolderData -> Text -> LBS.ByteString -> IO ()
execUploadFile api ep folder filename content = do
  let zone = fnZone folder
      tokenBody = uploadTokenBodyBytes filename (LBS.length content)
      tokenReq' =
        (uploadTokenReq zone ep)
          { requestBody = RequestBodyLBS tokenBody
          , requestHeaders = (hContentType, "text/plain") : requestHeaders (uploadTokenReq zone ep)
          }
  (docId, uploadUrl) <- fetchWith "uploadFile (token)" api tokenReq' parseUploadTokenResponse
  uploadReq <- buildUploadReq filename content uploadUrl
  receipt <- fetchWith "uploadFile (content)" api uploadReq parseUploadReceiptResponse
  nowMs <- currentTimeMs
  let fDocId = folderDocId folder
      commitBody = buildCommitBody docId fDocId filename receipt nowMs
      commitReq' =
        (commitUploadReq zone ep)
          { requestBody = RequestBodyLBS commitBody
          , requestHeaders = (hContentType, "text/plain") : requestHeaders (commitUploadReq zone ep)
          }
  commitResp <- rawRequest api commitReq'
  checkStatus commitResp


uploadTokenBodyBytes :: Text -> Int64 -> LBS.ByteString
uploadTokenBodyBytes filename size =
  encode $
    object
      [ "filename" .= filename
      , "type" .= ("FILE" :: Text)
      , "content_type" .= ("" :: Text)
      , "size" .= size
      ]


buildUploadReq :: Text -> LBS.ByteString -> Text -> IO Request
buildUploadReq filename content url = do
  baseReq <- getReqFromUrl url
  let part =
        (partLBS filename content)
          { partFilename = Just (Text.unpack filename)
          , partContentType = Just "application/octet-stream"
          }
  formDataBody [part] baseReq


buildCommitBody :: Text -> Text -> Text -> UploadReceipt -> Int64 -> LBS.ByteString
buildCommitBody docId folderId filename receipt nowMs =
  encode $
    object
      [ "data" .= dataObj
      , "command" .= ("add_file" :: Text)
      , "create_short_guid" .= True
      , "document_id" .= docId
      , "path"
          .= object
            [ "starting_document_id" .= folderId
            , "path" .= filename
            ]
      , "allow_conflict" .= True
      , "file_flags"
          .= object
            [ "is_writable" .= True
            , "is_executable" .= False
            , "is_hidden" .= False
            ]
      , "mtime" .= nowMs
      , "btime" .= nowMs
      ]
 where
  dataObj = object $ baseData ++ receiptField
  baseData =
    [ "signature" .= urFileChecksum receipt
    , "wrapping_key" .= urWrappingKey receipt
    , "reference_signature" .= urReferenceChecksum receipt
    , "size" .= urSize receipt
    ]
  receiptField = case urReceipt receipt of
    Nothing -> []
    Just r -> ["receipt" .= r]


currentTimeMs :: IO Int64
currentTimeMs = do
  t <- getPOSIXTime
  pure $ round (t * 1000)


nodeReq :: DriveEndpoints -> DriveNodeId -> Request
nodeReq ep nid =
  base
    { requestBody = RequestBodyLBS (nodeDetailsBody nid)
    , requestHeaders = (hContentType, "application/json") : requestHeaders base
    }
 where
  base = nodeDetailsReq ep


getReqFromUrl :: Text -> IO Request
getReqFromUrl = parseRequest . Text.unpack


fetchWith :: String -> Api -> Request -> (Value -> Parser a) -> IO a
fetchWith ctx api r parseF = do
  resp <- rawRequest api r
  checkStatus resp
  case eitherDecode (responseBody resp) of
    Left err -> throwIO (DriveParseError (ctx <> ": JSON decode error: " <> err))
    Right val -> either (throwIO . DriveParseError) pure $ parseEither parseF val


checkStatus :: Response a -> IO ()
checkStatus resp =
  let code = statusCode (responseStatus resp)
   in when (code >= 400) $ throwIO (DriveHttpError code)