api.go 57 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743
  1. package api
  2. import (
  3. ctx "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "net/http"
  8. "os"
  9. "path"
  10. "sort"
  11. "strings"
  12. "sync"
  13. "time"
  14. "connectrpc.com/connect"
  15. "google.golang.org/protobuf/encoding/protojson"
  16. apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
  17. apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect"
  18. "github.com/google/uuid"
  19. log "github.com/sirupsen/logrus"
  20. acl "github.com/OliveTin/OliveTin/internal/acl"
  21. auth "github.com/OliveTin/OliveTin/internal/auth"
  22. authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
  23. config "github.com/OliveTin/OliveTin/internal/config"
  24. entities "github.com/OliveTin/OliveTin/internal/entities"
  25. executor "github.com/OliveTin/OliveTin/internal/executor"
  26. installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
  27. "github.com/OliveTin/OliveTin/internal/tpl"
  28. connectproto "go.akshayshah.org/connectproto"
  29. )
  30. type oliveTinAPI struct {
  31. executor *executor.Executor
  32. cfg *config.Config
  33. // streamingClients is a set of currently connected clients.
  34. // The empty struct value models set semantics (keys only) and keeps add/remove O(1).
  35. // We use a map for efficient membership and deletion; ordering is not required.
  36. streamingClients map[*streamingClient]struct{}
  37. streamingClientsMutex sync.RWMutex
  38. }
  39. // This is used to avoid race conditions when iterating over the connectedClients map.
  40. // and holds the lock for as minimal time as possible to avoid blocking the API for too long.
  41. func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
  42. api.streamingClientsMutex.RLock()
  43. defer api.streamingClientsMutex.RUnlock()
  44. clients := make([]*streamingClient, 0, len(api.streamingClients))
  45. for client := range api.streamingClients {
  46. clients = append(clients, client)
  47. }
  48. return clients
  49. }
  50. type streamingClient struct {
  51. channel chan *apiv1.EventStreamResponse
  52. AuthenticatedUser *authpublic.AuthenticatedUser
  53. heartbeatStopOnce sync.Once
  54. heartbeatStop chan struct{}
  55. heartbeatDone chan struct{}
  56. }
  57. func (c *streamingClient) stopHeartbeat() {
  58. if c.heartbeatStop == nil || c.heartbeatDone == nil {
  59. return
  60. }
  61. c.heartbeatStopOnce.Do(func() {
  62. close(c.heartbeatStop)
  63. })
  64. <-c.heartbeatDone
  65. }
  66. // trySendEventToClient sends msg to the client's channel. Returns false if the channel is full or closed.
  67. func (api *oliveTinAPI) trySendEventToClient(client *streamingClient, msg *apiv1.EventStreamResponse) bool {
  68. if client == nil || msg == nil {
  69. return false
  70. }
  71. sent := sendToStreamingClientChannel(client.channel, msg)
  72. if !sent {
  73. log.Warnf("EventStream: client channel is full or closed, removing client")
  74. }
  75. return sent
  76. }
  77. func sendToStreamingClientChannel(ch chan *apiv1.EventStreamResponse, msg *apiv1.EventStreamResponse) (sent bool) {
  78. defer func() {
  79. if recover() != nil {
  80. sent = false
  81. }
  82. }()
  83. select {
  84. case ch <- msg:
  85. return true
  86. default:
  87. return false
  88. }
  89. }
  90. func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.KillActionRequest]) (*connect.Response[apiv1.KillActionResponse], error) {
  91. ret := &apiv1.KillActionResponse{
  92. ExecutionTrackingId: req.Msg.ExecutionTrackingId,
  93. }
  94. var execReqLogEntry *executor.InternalLogEntry
  95. execReqLogEntry, ret.Found = api.executor.GetLog(req.Msg.ExecutionTrackingId)
  96. if !ret.Found {
  97. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", req.Msg.ExecutionTrackingId))
  98. }
  99. if execReqLogEntry.Binding == nil {
  100. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", req.Msg.ExecutionTrackingId))
  101. }
  102. action := execReqLogEntry.Binding.Action
  103. if action == nil {
  104. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", req.Msg.ExecutionTrackingId))
  105. }
  106. log.Warnf("Killing execution request by tracking ID: %v", req.Msg.ExecutionTrackingId)
  107. user := auth.UserFromApiCall(ctx, req, api.cfg)
  108. api.killActionByTrackingId(user, action, execReqLogEntry, ret)
  109. return connect.NewResponse(ret), nil
  110. }
  111. func (api *oliveTinAPI) killActionByTrackingId(user *authpublic.AuthenticatedUser, action *config.Action, execReqLogEntry *executor.InternalLogEntry, ret *apiv1.KillActionResponse) {
  112. if !acl.IsAllowedKill(api.cfg, user, action) {
  113. log.Warnf("Killing execution request not possible - user not allowed to kill this action: %v", execReqLogEntry.ExecutionTrackingID)
  114. ret.Killed = false
  115. return
  116. }
  117. err := api.executor.Kill(execReqLogEntry)
  118. if err != nil {
  119. log.Warnf("Killing execution request err: %v", err)
  120. ret.AlreadyCompleted = true
  121. ret.Killed = false
  122. } else {
  123. ret.Killed = true
  124. }
  125. }
  126. func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.StartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
  127. pair, err := api.findBindingByIDOrNotFound(req.Msg.BindingId)
  128. if err != nil {
  129. return nil, err
  130. }
  131. authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
  132. args := startActionArgumentsFromProto(req.Msg.Arguments)
  133. justification := resolveStartJustification(pair.Action, pair, req.Msg.Justification, args)
  134. if err := validateJustificationRequired(pair.Action, justification, authenticatedUser); err != nil {
  135. return nil, connectInvalidJustification(err)
  136. }
  137. execReq := executor.ExecutionRequest{
  138. Binding: pair,
  139. TrackingID: req.Msg.UniqueTrackingId,
  140. Arguments: args,
  141. Justification: justification,
  142. AuthenticatedUser: authenticatedUser,
  143. Cfg: api.cfg,
  144. }
  145. api.executor.ExecRequest(&execReq)
  146. return connect.NewResponse(&apiv1.StartActionResponse{
  147. ExecutionTrackingId: execReq.TrackingID,
  148. }), nil
  149. }
  150. func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1.PasswordHashRequest]) (*connect.Response[apiv1.PasswordHashResponse], error) {
  151. hash, err := createHash(req.Msg.Password)
  152. if err != nil {
  153. if errors.Is(err, ErrArgon2Busy) {
  154. return nil, connect.NewError(connect.CodeResourceExhausted, err)
  155. }
  156. return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating hash: %w", err))
  157. }
  158. ret := &apiv1.PasswordHashResponse{
  159. Hash: hash,
  160. }
  161. return connect.NewResponse(ret), nil
  162. }
  163. func (api *oliveTinAPI) cookieSecure(header http.Header) bool {
  164. useTLS := header.Get("X-Forwarded-Proto") == "https"
  165. return useTLS || api.cfg.Security.ForceSecureCookies
  166. }
  167. func (api *oliveTinAPI) applyLocalLoginResult(req *apiv1.LocalUserLoginRequest, response *connect.Response[apiv1.LocalUserLoginResponse], match bool, secure bool) {
  168. if match {
  169. user := api.cfg.FindUserByUsername(req.Username)
  170. if user != nil {
  171. sid := uuid.NewString()
  172. auth.RegisterUserSession(api.cfg, "local", sid, user.Username)
  173. log.WithFields(log.Fields{"username": user.Username}).Info("LocalUserLogin: Session created and registered")
  174. cookie := &http.Cookie{
  175. Name: "olivetin-sid-local",
  176. Value: sid,
  177. MaxAge: 31556952,
  178. HttpOnly: true,
  179. Path: "/",
  180. Secure: secure,
  181. SameSite: http.SameSiteLaxMode,
  182. }
  183. response.Header().Set("Set-Cookie", cookie.String())
  184. log.WithFields(log.Fields{"username": user.Username}).Info("LocalUserLogin: User logged in successfully.")
  185. } else {
  186. log.WithFields(log.Fields{"username": req.Username}).Warn("LocalUserLogin: Password matched but user lookup failed.")
  187. }
  188. } else {
  189. log.WithFields(log.Fields{"username": req.Username}).Warn("LocalUserLogin: User login failed.")
  190. }
  191. }
  192. func (api *oliveTinAPI) localUserLoginEarlyReject(req *connect.Request[apiv1.LocalUserLoginRequest]) *connect.Response[apiv1.LocalUserLoginResponse] {
  193. if !api.cfg.AuthLocalUsers.Enabled {
  194. return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false})
  195. }
  196. if isLocalInteractiveLoginDisabledForUser(api.cfg, req.Msg.Username) {
  197. log.WithFields(log.Fields{"username": req.Msg.Username}).Debug("LocalUserLogin: interactive login disabled (no password configured)")
  198. return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false})
  199. }
  200. return nil
  201. }
  202. func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) {
  203. if early := api.localUserLoginEarlyReject(req); early != nil {
  204. return early, nil
  205. }
  206. match, err := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password)
  207. if err != nil {
  208. if errors.Is(err, ErrArgon2Busy) {
  209. return nil, connect.NewError(connect.CodeResourceExhausted, err)
  210. }
  211. return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("checking password: %w", err))
  212. }
  213. response := connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: match})
  214. api.applyLocalLoginResult(req.Msg, response, match, api.cookieSecure(req.Header()))
  215. return response, nil
  216. }
  217. func (api *oliveTinAPI) startActionAndWaitRun(binding *executor.ActionBinding, args map[string]string, justification string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
  218. execReq := executor.ExecutionRequest{
  219. Binding: binding,
  220. TrackingID: uuid.NewString(),
  221. Arguments: args,
  222. Justification: justification,
  223. AuthenticatedUser: user,
  224. Cfg: api.cfg,
  225. }
  226. wg, _ := api.executor.ExecRequest(&execReq)
  227. wg.Wait()
  228. return api.executor.GetLog(execReq.TrackingID)
  229. }
  230. func (api *oliveTinAPI) findBindingOrNotFound(actionId string) (*executor.ActionBinding, error) {
  231. binding := api.executor.FindBindingByID(actionId)
  232. if binding == nil || binding.Action == nil {
  233. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", actionId))
  234. }
  235. return binding, nil
  236. }
  237. func (api *oliveTinAPI) findBindingByIDOrNotFound(bindingId string) (*executor.ActionBinding, error) {
  238. return api.findBindingOrNotFound(bindingId)
  239. }
  240. func (api *oliveTinAPI) startActionAndWaitLogEntry(binding *executor.ActionBinding, args map[string]string, justification string, user *authpublic.AuthenticatedUser) (*apiv1.LogEntry, error) {
  241. internalLogEntry, ok := api.startActionAndWaitRun(binding, args, justification, user)
  242. if !ok {
  243. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
  244. }
  245. return api.logEntryForAllowedViewer(internalLogEntry, user)
  246. }
  247. func (api *oliveTinAPI) logEntryForAllowedViewer(internalLogEntry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) (*apiv1.LogEntry, error) {
  248. if err := api.requireLogEntryAllowed(internalLogEntry, user); err != nil {
  249. return nil, err
  250. }
  251. return api.internalLogEntryToPb(internalLogEntry, user), nil
  252. }
  253. func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
  254. binding, err := api.findBindingOrNotFound(req.Msg.ActionId)
  255. if err != nil {
  256. return nil, err
  257. }
  258. user := auth.UserFromApiCall(ctx, req, api.cfg)
  259. args := startActionArgumentsFromProto(req.Msg.Arguments)
  260. justification := resolveStartJustification(binding.Action, binding, req.Msg.Justification, args)
  261. if err := validateJustificationRequired(binding.Action, justification, user); err != nil {
  262. return nil, connectInvalidJustification(err)
  263. }
  264. logEntry, err := api.startActionAndWaitLogEntry(binding, args, justification, user)
  265. if err != nil {
  266. return nil, err
  267. }
  268. return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
  269. LogEntry: logEntry,
  270. }), nil
  271. }
  272. func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetRequest]) (*connect.Response[apiv1.StartActionByGetResponse], error) {
  273. binding := api.executor.FindBindingByID(req.Msg.ActionId)
  274. if binding == nil || binding.Action == nil {
  275. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
  276. }
  277. args := make(map[string]string)
  278. execReq := executor.ExecutionRequest{
  279. Binding: binding,
  280. TrackingID: uuid.NewString(),
  281. Arguments: args,
  282. AuthenticatedUser: auth.UserFromApiCall(ctx, req, api.cfg),
  283. Cfg: api.cfg,
  284. }
  285. _, uniqueTrackingId := api.executor.ExecRequest(&execReq)
  286. return connect.NewResponse(&apiv1.StartActionByGetResponse{
  287. ExecutionTrackingId: uniqueTrackingId,
  288. }), nil
  289. }
  290. func (api *oliveTinAPI) runBindingAndWait(binding *executor.ActionBinding, args map[string]string, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, bool) {
  291. execReq := executor.ExecutionRequest{
  292. Binding: binding,
  293. TrackingID: uuid.NewString(),
  294. Arguments: args,
  295. AuthenticatedUser: user,
  296. Cfg: api.cfg,
  297. }
  298. wg, _ := api.executor.ExecRequest(&execReq)
  299. wg.Wait()
  300. return api.executor.GetLog(execReq.TrackingID)
  301. }
  302. func (api *oliveTinAPI) startActionByGetAndWaitLogEntry(binding *executor.ActionBinding, user *authpublic.AuthenticatedUser) (*apiv1.LogEntry, error) {
  303. internalLogEntry, ok := api.runBindingAndWait(binding, map[string]string{}, user)
  304. if !ok {
  305. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
  306. }
  307. return api.logEntryForAllowedViewer(internalLogEntry, user)
  308. }
  309. func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
  310. binding := api.executor.FindBindingByID(req.Msg.ActionId)
  311. if binding == nil || binding.Action == nil {
  312. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.ActionId))
  313. }
  314. user := auth.UserFromApiCall(ctx, req, api.cfg)
  315. logEntry, err := api.startActionByGetAndWaitLogEntry(binding, user)
  316. if err != nil {
  317. return nil, err
  318. }
  319. return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
  320. LogEntry: logEntry,
  321. }), nil
  322. }
  323. func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string {
  324. if logEntry.Binding == nil || logEntry.Binding.Action == nil {
  325. return ""
  326. }
  327. expiryUnix := api.executor.GetTimeUntilAvailable(logEntry.Binding)
  328. if expiryUnix <= 0 {
  329. return ""
  330. }
  331. return time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05")
  332. }
  333. func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *authpublic.AuthenticatedUser) *apiv1.LogEntry {
  334. pble := &apiv1.LogEntry{
  335. ActionTitle: logEntry.ActionTitle,
  336. ActionIcon: logEntry.ActionIcon,
  337. DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
  338. DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
  339. DatetimeIndex: logEntry.Index,
  340. Output: logEntry.Output,
  341. TimedOut: logEntry.TimedOut,
  342. Blocked: logEntry.Blocked,
  343. Queued: logEntry.Queued,
  344. QueuedForGroup: logEntry.QueuedForGroup,
  345. ExitCode: logEntry.ExitCode,
  346. Tags: logEntry.Tags,
  347. ExecutionTrackingId: logEntry.ExecutionTrackingID,
  348. ExecutionStarted: logEntry.ExecutionStarted,
  349. ExecutionFinished: logEntry.ExecutionFinished,
  350. User: logEntry.Username,
  351. BindingId: logEntry.GetBindingId(),
  352. DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
  353. Justification: logEntry.Justification,
  354. Arguments: logEntryArgumentsToProto(logEntry.Arguments),
  355. }
  356. if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
  357. pble.CanKill = acl.IsAllowedKill(api.cfg, authenticatedUser, logEntry.Binding.Action)
  358. }
  359. return pble
  360. }
  361. func getExecutionStatusByTrackingID(api *oliveTinAPI, executionTrackingId string) *executor.InternalLogEntry {
  362. logEntry, ok := api.executor.GetLog(executionTrackingId)
  363. if !ok {
  364. return nil
  365. }
  366. return logEntry
  367. }
  368. // This is the actual action ID, not the binding ID.
  369. func getMostRecentExecutionStatusByActionId(api *oliveTinAPI, actionId string) *executor.InternalLogEntry {
  370. var ile *executor.InternalLogEntry
  371. binding := api.executor.FindBindingByID(actionId)
  372. if binding == nil {
  373. return nil
  374. }
  375. logs := api.executor.GetLogsByBindingId(binding.ID)
  376. if len(logs) == 0 {
  377. return nil
  378. }
  379. if len(logs) == 0 {
  380. return nil
  381. } else {
  382. // Get last log entry
  383. ile = logs[len(logs)-1]
  384. }
  385. return ile
  386. }
  387. func (api *oliveTinAPI) resolveExecutionStatusForView(msg *apiv1.ExecutionStatusRequest, user *authpublic.AuthenticatedUser) (*executor.InternalLogEntry, error) {
  388. ile := api.getExecutionStatusByRequest(msg)
  389. if ile == nil {
  390. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s or action ID %s", msg.ExecutionTrackingId, msg.ActionId))
  391. }
  392. if !isValidLogEntry(ile) || !api.isLogEntryAllowed(ile, user) {
  393. return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied to view this execution"))
  394. }
  395. return ile, nil
  396. }
  397. func (api *oliveTinAPI) getExecutionStatusByRequest(msg *apiv1.ExecutionStatusRequest) *executor.InternalLogEntry {
  398. if msg.ExecutionTrackingId != "" {
  399. return getExecutionStatusByTrackingID(api, msg.ExecutionTrackingId)
  400. }
  401. return getMostRecentExecutionStatusByActionId(api, msg.ActionId)
  402. }
  403. func dashboardNavigationTargetsToPb(targets []executor.DashboardNavigationTarget) []*apiv1.DashboardNavigationTarget {
  404. if len(targets) == 0 {
  405. return nil
  406. }
  407. result := make([]*apiv1.DashboardNavigationTarget, 0, len(targets))
  408. for _, target := range targets {
  409. result = append(result, &apiv1.DashboardNavigationTarget{
  410. Title: target.Title,
  411. EntityType: target.EntityType,
  412. EntityKey: target.EntityKey,
  413. Path: target.Path,
  414. })
  415. }
  416. return result
  417. }
  418. func (api *oliveTinAPI) executionStatusBackToDashboards(ile *executor.InternalLogEntry) []*apiv1.DashboardNavigationTarget {
  419. if ile == nil || ile.Binding == nil {
  420. return nil
  421. }
  422. return dashboardNavigationTargetsToPb(ile.Binding.OnDashboards)
  423. }
  424. func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) {
  425. user := auth.UserFromApiCall(ctx, req, api.cfg)
  426. if err := api.checkDashboardAccess(user); err != nil {
  427. return nil, err
  428. }
  429. ile, err := api.resolveExecutionStatusForView(req.Msg, user)
  430. if err != nil {
  431. return nil, err
  432. }
  433. res := &apiv1.ExecutionStatusResponse{
  434. LogEntry: api.internalLogEntryToPb(ile, user),
  435. BackToDashboards: api.executionStatusBackToDashboards(ile),
  436. }
  437. return connect.NewResponse(res), nil
  438. }
  439. func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) {
  440. user := auth.UserFromApiCall(ctx, req, api.cfg)
  441. auth.RevokeSessionForProvider(api.cfg, user.Provider, user.SID)
  442. log.WithFields(log.Fields{
  443. "username": user.Username,
  444. "provider": user.Provider,
  445. }).Info("Logout: User logged out")
  446. response := connect.NewResponse(&apiv1.LogoutResponse{})
  447. secure := api.cookieSecure(req.Header())
  448. // Clear the local authentication cookie by setting it to expire
  449. localCookie := &http.Cookie{
  450. Name: "olivetin-sid-local",
  451. Value: "",
  452. MaxAge: -1, // This tells the browser to delete the cookie
  453. HttpOnly: true,
  454. Path: "/",
  455. Secure: secure,
  456. SameSite: http.SameSiteLaxMode,
  457. }
  458. response.Header().Set("Set-Cookie", localCookie.String())
  459. // Clear the OAuth2 authentication cookie by setting it to expire
  460. oauth2Cookie := &http.Cookie{
  461. Name: "olivetin-sid-oauth",
  462. Value: "",
  463. MaxAge: -1, // This tells the browser to delete the cookie
  464. HttpOnly: true,
  465. Path: "/",
  466. Secure: secure,
  467. SameSite: http.SameSiteLaxMode,
  468. }
  469. response.Header().Add("Set-Cookie", oauth2Cookie.String())
  470. return response, nil
  471. }
  472. func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[apiv1.GetActionBindingRequest]) (*connect.Response[apiv1.GetActionBindingResponse], error) {
  473. user := auth.UserFromApiCall(ctx, req, api.cfg)
  474. if err := api.checkDashboardAccess(user); err != nil {
  475. return nil, err
  476. }
  477. resp, err := api.getActionBindingResponse(user, req.Msg.BindingId)
  478. if err != nil {
  479. return nil, err
  480. }
  481. return connect.NewResponse(resp), nil
  482. }
  483. func (api *oliveTinAPI) getActionBindingResponse(user *authpublic.AuthenticatedUser, bindingId string) (*apiv1.GetActionBindingResponse, error) {
  484. binding := api.executor.FindBindingByID(bindingId)
  485. if binding == nil || binding.Action == nil {
  486. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", bindingId))
  487. }
  488. if !api.userCanViewAction(user, binding.Action) {
  489. return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
  490. }
  491. return &apiv1.GetActionBindingResponse{
  492. Action: buildAction(binding, api.createDashboardRenderRequest(user, "", "")),
  493. BackToDashboards: dashboardNavigationTargetsToPb(binding.OnDashboards),
  494. }, nil
  495. }
  496. func (api *oliveTinAPI) userCanViewAction(user *authpublic.AuthenticatedUser, action *config.Action) bool {
  497. if user == nil {
  498. return true
  499. }
  500. return acl.IsAllowedView(api.cfg, user, action)
  501. }
  502. func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) {
  503. user := auth.UserFromApiCall(ctx, req, api.cfg)
  504. if err := api.checkDashboardAccess(user); err != nil {
  505. return nil, err
  506. }
  507. entityType := ""
  508. entityKey := ""
  509. if req.Msg != nil {
  510. entityType = req.Msg.EntityType
  511. entityKey = req.Msg.EntityKey
  512. }
  513. dashboardRenderRequest := api.createDashboardRenderRequest(user, entityType, entityKey)
  514. if api.isDefaultDashboard(req.Msg.Title) {
  515. return api.buildDefaultDashboardResponse(dashboardRenderRequest)
  516. }
  517. return api.buildCustomDashboardResponse(dashboardRenderRequest, req.Msg.Title)
  518. }
  519. func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser) error {
  520. if user.IsGuest() && api.cfg.AuthRequireGuestsToLogin {
  521. return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("guests are not allowed to access the dashboard"))
  522. }
  523. return nil
  524. }
  525. func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
  526. rr := &DashboardRenderRequest{
  527. AuthenticatedUser: user,
  528. cfg: api.cfg,
  529. ex: api.executor,
  530. EntityType: entityType,
  531. EntityKey: entityKey,
  532. }
  533. populateActiveBindingStates(rr)
  534. return rr
  535. }
  536. func (api *oliveTinAPI) isDefaultDashboard(title string) bool {
  537. return title == "default" || title == "" || title == "Actions"
  538. }
  539. func (api *oliveTinAPI) buildDefaultDashboardResponse(rr *DashboardRenderRequest) (*connect.Response[apiv1.GetDashboardResponse], error) {
  540. db := buildDefaultDashboard(rr)
  541. res := &apiv1.GetDashboardResponse{
  542. Dashboard: db,
  543. }
  544. return connect.NewResponse(res), nil
  545. }
  546. func (api *oliveTinAPI) buildCustomDashboardResponse(rr *DashboardRenderRequest, title string) (*connect.Response[apiv1.GetDashboardResponse], error) {
  547. res := &apiv1.GetDashboardResponse{
  548. Dashboard: renderDashboard(rr, title),
  549. }
  550. return connect.NewResponse(res), nil
  551. }
  552. func resolveLogsPageSize(requestPageSize, defaultPageSize int64) int64 {
  553. if requestPageSize == 0 {
  554. return defaultPageSize
  555. }
  556. if requestPageSize < 10 {
  557. return 10
  558. }
  559. if requestPageSize > 100 {
  560. return 100
  561. }
  562. return requestPageSize
  563. }
  564. func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetLogsRequest]) (*connect.Response[apiv1.GetLogsResponse], error) {
  565. user := auth.UserFromApiCall(ctx, req, api.cfg)
  566. if err := api.checkDashboardAccess(user); err != nil {
  567. return nil, err
  568. }
  569. pageSize := resolveLogsPageSize(req.Msg.GetPageSize(), api.cfg.LogHistoryPageSize)
  570. logEntries, paging, err := api.executor.GetLogTrackingIdsACL(api.cfg, user, req.Msg.StartOffset, pageSize, req.Msg.DateFilter, req.Msg.GetFilter())
  571. if err != nil {
  572. return nil, connect.NewError(connect.CodeInvalidArgument, err)
  573. }
  574. ret := &apiv1.GetLogsResponse{}
  575. for _, le := range logEntries {
  576. ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
  577. }
  578. ret.CountRemaining = paging.CountRemaining
  579. ret.PageSize = paging.PageSize
  580. ret.TotalCount = paging.TotalCount
  581. ret.StartOffset = paging.StartOffset
  582. return connect.NewResponse(ret), nil
  583. }
  584. // isValidLogEntry checks if a log entry has all required fields populated.
  585. func isValidLogEntry(e *executor.InternalLogEntry) bool {
  586. return e != nil && e.Binding != nil && e.Binding.Action != nil
  587. }
  588. // isLogEntryAllowed checks if a log entry is allowed to be viewed by the user.
  589. func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
  590. if user == nil || !isValidLogEntry(e) {
  591. return false
  592. }
  593. return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
  594. }
  595. func (api *oliveTinAPI) requireLogEntryAllowed(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) error {
  596. if api.isLogEntryAllowed(entry, user) {
  597. return nil
  598. }
  599. return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied to view this execution"))
  600. }
  601. // mayViewExecutionEvent returns whether the user is allowed to receive this execution event (for EventStream ACL).
  602. func (api *oliveTinAPI) mayViewExecutionEvent(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
  603. if user == nil {
  604. return false
  605. }
  606. return isValidLogEntry(entry) && api.isLogEntryAllowed(entry, user)
  607. }
  608. // buildEmptyPageResponse creates a response for an empty page.
  609. func buildEmptyPageResponse(page pageInfo) *apiv1.GetActionLogsResponse {
  610. return &apiv1.GetActionLogsResponse{
  611. CountRemaining: 0,
  612. PageSize: page.size,
  613. TotalCount: page.total,
  614. StartOffset: page.start,
  615. }
  616. }
  617. // calculateReversedIndices computes the reversed indices for newest-first pagination.
  618. func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) {
  619. startIdx := page.total - page.end
  620. endIdx := page.total - page.start
  621. if startIdx < 0 {
  622. startIdx = 0
  623. }
  624. if endIdx > int64(filteredLen) {
  625. endIdx = int64(filteredLen)
  626. }
  627. return startIdx, endIdx
  628. }
  629. // buildActionLogsResponse builds the response with paginated log entries (newest first).
  630. func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *authpublic.AuthenticatedUser) *apiv1.GetActionLogsResponse {
  631. startIdx, endIdx := calculateReversedIndices(page, len(filtered))
  632. ret := &apiv1.GetActionLogsResponse{}
  633. chunk := filtered[int(startIdx):int(endIdx)]
  634. for i := len(chunk) - 1; i >= 0; i-- {
  635. ret.Logs = append(ret.Logs, api.internalLogEntryToPb(chunk[i], user))
  636. }
  637. ret.CountRemaining = page.start
  638. ret.PageSize = page.size
  639. ret.TotalCount = page.total
  640. ret.StartOffset = page.start
  641. return ret
  642. }
  643. func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) {
  644. user := auth.UserFromApiCall(ctx, req, api.cfg)
  645. if err := api.checkDashboardAccess(user); err != nil {
  646. return nil, err
  647. }
  648. filtered := api.filterLogsByACL(api.executor.GetLogsByBindingId(req.Msg.ActionId), user)
  649. page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
  650. if page.empty {
  651. return connect.NewResponse(buildEmptyPageResponse(page)), nil
  652. }
  653. return connect.NewResponse(api.buildActionLogsResponse(filtered, page, user)), nil
  654. }
  655. func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *authpublic.AuthenticatedUser) []*executor.InternalLogEntry {
  656. filtered := make([]*executor.InternalLogEntry, 0, len(entries))
  657. for _, e := range entries {
  658. if !isValidLogEntry(e) {
  659. continue
  660. }
  661. if api.isLogEntryAllowed(e, user) {
  662. filtered = append(filtered, e)
  663. }
  664. }
  665. return filtered
  666. }
  667. type pageInfo struct {
  668. total int64
  669. size int64
  670. start int64
  671. end int64
  672. empty bool
  673. }
  674. func paginate(total int64, size int64, start int64) pageInfo {
  675. if start < 0 {
  676. start = 0
  677. }
  678. if start >= total {
  679. return pageInfo{total: total, size: size, start: start, end: start, empty: true}
  680. }
  681. end := start + size
  682. if end > total {
  683. end = total
  684. }
  685. return pageInfo{total: total, size: size, start: start, end: end, empty: false}
  686. }
  687. /*
  688. This function is ONLY a helper for the UI - the arguments are validated properly
  689. on the StartAction -> Executor chain. This is here basically to provide helpful
  690. error messages more quickly before starting the action.
  691. It uses the same validation logic as the executor, including mangling argument
  692. values (e.g., datetime formatting, checkbox title-to-value conversion).
  693. */
  694. func (api *oliveTinAPI) argumentNotFoundForValidation(msg *apiv1.ValidateArgumentTypeRequest) bool {
  695. if msg.BindingId == "" || msg.ArgumentName == "" {
  696. return false
  697. }
  698. arg, _ := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
  699. return arg == nil
  700. }
  701. func (api *oliveTinAPI) validateArgumentTypeBindingAccess(user *authpublic.AuthenticatedUser, msg *apiv1.ValidateArgumentTypeRequest) error {
  702. if msg == nil || msg.BindingId == "" {
  703. return nil
  704. }
  705. return api.errUnlessUserMayValidateArgumentTypeForBinding(user, msg.BindingId)
  706. }
  707. func (api *oliveTinAPI) errUnlessUserMayValidateArgumentTypeForBinding(user *authpublic.AuthenticatedUser, bindingID string) error {
  708. binding := api.executor.FindBindingByID(bindingID)
  709. if binding == nil || binding.Action == nil {
  710. return connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", bindingID))
  711. }
  712. if !api.userCanViewAction(user, binding.Action) {
  713. return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
  714. }
  715. return nil
  716. }
  717. func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
  718. user := auth.UserFromApiCall(ctx, req, api.cfg)
  719. if err := api.checkDashboardAccess(user); err != nil {
  720. return nil, err
  721. }
  722. if err := api.validateArgumentTypeBindingAccess(user, req.Msg); err != nil {
  723. return nil, err
  724. }
  725. if api.argumentNotFoundForValidation(req.Msg) {
  726. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId))
  727. }
  728. return api.validateArgumentTypeConnectResponse(req.Msg)
  729. }
  730. func (api *oliveTinAPI) validateArgumentTypeConnectResponse(msg *apiv1.ValidateArgumentTypeRequest) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
  731. err := api.validateArgumentTypeInternal(msg)
  732. desc := ""
  733. if err != nil {
  734. desc = err.Error()
  735. }
  736. return connect.NewResponse(&apiv1.ValidateArgumentTypeResponse{
  737. Valid: err == nil,
  738. Description: desc,
  739. }), nil
  740. }
  741. func (api *oliveTinAPI) validateArgumentTypeInternal(msg *apiv1.ValidateArgumentTypeRequest) error {
  742. if msg.BindingId == "" || msg.ArgumentName == "" {
  743. return executor.TypeSafetyCheck("", msg.Value, msg.Type)
  744. }
  745. arg, action := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
  746. if arg == nil {
  747. return fmt.Errorf("argument not found")
  748. }
  749. return executor.ValidateArgument(arg, msg.Value, action)
  750. }
  751. func (api *oliveTinAPI) findArgumentForValidation(bindingId string, argumentName string) (*config.ActionArgument, *config.Action) {
  752. binding := api.executor.FindBindingByID(bindingId)
  753. if binding == nil || binding.Action == nil {
  754. return nil, nil
  755. }
  756. arg := api.findArgumentByName(binding.Action, argumentName)
  757. return arg, binding.Action
  758. }
  759. func (api *oliveTinAPI) findArgumentByName(action *config.Action, name string) *config.ActionArgument {
  760. for i := range action.Arguments {
  761. if action.Arguments[i].Name == name {
  762. return &action.Arguments[i]
  763. }
  764. }
  765. return nil
  766. }
  767. func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) {
  768. user := auth.UserFromApiCall(ctx, req, api.cfg)
  769. if err := api.checkDashboardAccess(user); err != nil {
  770. return nil, err
  771. }
  772. res := &apiv1.WhoAmIResponse{
  773. AuthenticatedUser: user.Username,
  774. Usergroup: user.UsergroupLine,
  775. Provider: user.Provider,
  776. Sid: user.SID,
  777. Acls: user.Acls,
  778. }
  779. return connect.NewResponse(res), nil
  780. }
  781. func (api *oliveTinAPI) ServerDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.ServerDiagnosticsRequest]) (*connect.Response[apiv1.ServerDiagnosticsResponse], error) {
  782. user := auth.UserFromApiCall(ctx, req, api.cfg)
  783. redactVersion := !user.EffectivePolicy.ShowVersionNumber
  784. diagnostics := installationinfo.GetServerDiagnostics(redactVersion)
  785. if !api.cfg.InsecureAllowDumpServerDiagnostics {
  786. log.Info(diagnostics)
  787. diagnostics = "Your Server Diagnostics have been logged to OliveTin logs.\n\nIf you are in a safe network, you can temporarily set `insecureAllowDumpServerDiagnostics: true` in your config.yaml, restart OliveTin, and refresh this page - it will put the output directly in the browser."
  788. }
  789. ret := &apiv1.ServerDiagnosticsResponse{
  790. Alert: diagnostics,
  791. }
  792. return connect.NewResponse(ret), nil
  793. }
  794. func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.DumpVarsRequest]) (*connect.Response[apiv1.DumpVarsResponse], error) {
  795. res := &apiv1.DumpVarsResponse{}
  796. if !api.cfg.InsecureAllowDumpVars {
  797. res.Alert = "Dumping variables is not allowed by default because it is insecure."
  798. return connect.NewResponse(res), nil
  799. }
  800. jsonstring, err := json.MarshalIndent(tpl.GetNewGeneralTemplateContext(), "", " ")
  801. if err != nil {
  802. log.WithError(err).Error("DumpVars: failed to marshal template context from GetNewGeneralTemplateContext")
  803. return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("dump vars: marshal template context: %w", err))
  804. }
  805. fmt.Printf("%s", jsonstring)
  806. res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpVars = false again after you don't need it anymore"
  807. return connect.NewResponse(res), nil
  808. }
  809. func debugBindingActionTitle(binding *executor.ActionBinding) string {
  810. if binding == nil || binding.Action == nil {
  811. return ""
  812. }
  813. return binding.Action.Title
  814. }
  815. func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Request[apiv1.DumpPublicIdActionMapRequest]) (*connect.Response[apiv1.DumpPublicIdActionMapResponse], error) {
  816. res := &apiv1.DumpPublicIdActionMapResponse{}
  817. res.Contents = make(map[string]*apiv1.DebugBinding)
  818. if !api.cfg.InsecureAllowDumpActionMap {
  819. res.Alert = "Dumping Public IDs is disallowed."
  820. return connect.NewResponse(res), nil
  821. }
  822. api.executor.MapActionBindingsLock.RLock()
  823. for k, v := range api.executor.MapActionBindings {
  824. res.Contents[k] = &apiv1.DebugBinding{
  825. ActionTitle: debugBindingActionTitle(v),
  826. }
  827. }
  828. api.executor.MapActionBindingsLock.RUnlock()
  829. res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
  830. return connect.NewResponse(res), nil
  831. }
  832. func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.GetReadyzRequest]) (*connect.Response[apiv1.GetReadyzResponse], error) {
  833. res := &apiv1.GetReadyzResponse{
  834. Status: "OK",
  835. }
  836. return connect.NewResponse(res), nil
  837. }
  838. func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error {
  839. log.Debugf("EventStream: %v", req.Msg)
  840. // Set X-Accel-Buffering header to disable nginx buffering for this stream
  841. // https://github.com/OliveTin/OliveTin/issues/765
  842. srv.ResponseHeader().Set("X-Accel-Buffering", "no")
  843. user := auth.UserFromApiCall(ctx, req, api.cfg)
  844. if err := api.checkDashboardAccess(user); err != nil {
  845. return err
  846. }
  847. client := &streamingClient{
  848. channel: make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
  849. AuthenticatedUser: user,
  850. heartbeatStop: make(chan struct{}),
  851. heartbeatDone: make(chan struct{}),
  852. }
  853. log.WithFields(log.Fields{
  854. "authenticatedUser": user.Username,
  855. }).Debugf("EventStream: client connected")
  856. api.streamingClientsMutex.Lock()
  857. api.streamingClients[client] = struct{}{}
  858. api.streamingClientsMutex.Unlock()
  859. go api.sendEventStreamHeartbeats(client)
  860. // loop over client channel and send events to connectedClient
  861. for msg := range client.channel {
  862. log.Debugf("Sending event to client: %v", msg)
  863. if err := srv.Send(msg); err != nil {
  864. log.Errorf("Error sending event to client: %v", err)
  865. // Remove disconnected client from the list
  866. api.removeClient(client)
  867. break
  868. }
  869. }
  870. log.Infof("EventStream: client disconnected")
  871. return nil
  872. }
  873. func (api *oliveTinAPI) sendEventStreamHeartbeats(client *streamingClient) {
  874. defer close(client.heartbeatDone)
  875. if !api.sendEventStreamHeartbeat(client) {
  876. go api.removeClient(client)
  877. return
  878. }
  879. ticker := time.NewTicker(10 * time.Second)
  880. defer ticker.Stop()
  881. api.runEventStreamHeartbeatLoop(client, ticker)
  882. }
  883. func (api *oliveTinAPI) runEventStreamHeartbeatLoop(client *streamingClient, ticker *time.Ticker) {
  884. for {
  885. if api.waitEventStreamHeartbeatOrDone(client.heartbeatStop, ticker) {
  886. return
  887. }
  888. if !api.sendEventStreamHeartbeat(client) {
  889. go api.removeClient(client)
  890. return
  891. }
  892. }
  893. }
  894. func (api *oliveTinAPI) waitEventStreamHeartbeatOrDone(done <-chan struct{}, ticker *time.Ticker) bool {
  895. select {
  896. case <-done:
  897. return true
  898. case <-ticker.C:
  899. return false
  900. }
  901. }
  902. func (api *oliveTinAPI) sendEventStreamHeartbeat(client *streamingClient) bool {
  903. msg := &apiv1.EventStreamResponse{
  904. Event: &apiv1.EventStreamResponse_Heartbeat{
  905. Heartbeat: &apiv1.EventHeartbeat{},
  906. },
  907. }
  908. return api.trySendEventToClient(client, msg)
  909. }
  910. func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) {
  911. if clientToRemove == nil {
  912. return
  913. }
  914. api.streamingClientsMutex.Lock()
  915. if _, exists := api.streamingClients[clientToRemove]; !exists {
  916. api.streamingClientsMutex.Unlock()
  917. return
  918. }
  919. delete(api.streamingClients, clientToRemove)
  920. api.streamingClientsMutex.Unlock()
  921. clientToRemove.stopHeartbeat()
  922. close(clientToRemove.channel)
  923. }
  924. func (api *oliveTinAPI) OnActionMapRebuilt() {
  925. toRemove := []*streamingClient{}
  926. for _, client := range api.copyOfStreamingClients() {
  927. msg := &apiv1.EventStreamResponse{
  928. Event: &apiv1.EventStreamResponse_ConfigChanged{
  929. ConfigChanged: &apiv1.EventConfigChanged{},
  930. },
  931. }
  932. if !api.trySendEventToClient(client, msg) {
  933. toRemove = append(toRemove, client)
  934. }
  935. }
  936. for _, client := range toRemove {
  937. api.removeClient(client)
  938. }
  939. }
  940. func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
  941. toRemove := []*streamingClient{}
  942. for _, client := range api.copyOfStreamingClients() {
  943. api.maybeSendExecutionStarted(client, ex, &toRemove)
  944. }
  945. for _, client := range toRemove {
  946. api.removeClient(client)
  947. }
  948. }
  949. func (api *oliveTinAPI) maybeSendExecutionStarted(client *streamingClient, ex *executor.InternalLogEntry, toRemove *[]*streamingClient) {
  950. if client == nil {
  951. return
  952. }
  953. if !api.mayViewExecutionEvent(ex, client.AuthenticatedUser) {
  954. return
  955. }
  956. msg := &apiv1.EventStreamResponse{
  957. Event: &apiv1.EventStreamResponse_ExecutionStarted{
  958. ExecutionStarted: &apiv1.EventExecutionStarted{
  959. LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
  960. },
  961. },
  962. }
  963. if !api.trySendEventToClient(client, msg) {
  964. *toRemove = append(*toRemove, client)
  965. }
  966. }
  967. func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) {
  968. toRemove := []*streamingClient{}
  969. for _, client := range api.copyOfStreamingClients() {
  970. api.maybeSendExecutionFinished(client, ile, &toRemove)
  971. }
  972. for _, client := range toRemove {
  973. api.removeClient(client)
  974. }
  975. }
  976. func (api *oliveTinAPI) maybeSendExecutionFinished(client *streamingClient, ile *executor.InternalLogEntry, toRemove *[]*streamingClient) {
  977. if client == nil {
  978. return
  979. }
  980. if !api.mayViewExecutionEvent(ile, client.AuthenticatedUser) {
  981. return
  982. }
  983. msg := &apiv1.EventStreamResponse{
  984. Event: &apiv1.EventStreamResponse_ExecutionFinished{
  985. ExecutionFinished: &apiv1.EventExecutionFinished{
  986. LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser),
  987. },
  988. },
  989. }
  990. if !api.trySendEventToClient(client, msg) {
  991. *toRemove = append(*toRemove, client)
  992. }
  993. }
  994. func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) {
  995. user := auth.UserFromApiCall(ctx, req, api.cfg)
  996. if err := api.checkDashboardAccess(user); err != nil {
  997. return nil, err
  998. }
  999. if !user.EffectivePolicy.ShowDiagnostics {
  1000. return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("diagnostics are not available for your account"))
  1001. }
  1002. res := &apiv1.GetDiagnosticsResponse{
  1003. SshFoundKey: installationinfo.Runtime.SshFoundKey,
  1004. SshFoundConfig: installationinfo.Runtime.SshFoundConfig,
  1005. ConfigIssues: api.buildConfigIssuesForUser(user),
  1006. }
  1007. return connect.NewResponse(res), nil
  1008. }
  1009. func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
  1010. user := auth.UserFromApiCall(ctx, req, api.cfg)
  1011. loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
  1012. showVersion := user.EffectivePolicy.ShowVersionNumber
  1013. currentVersion := ""
  1014. availableVersion := ""
  1015. if showVersion {
  1016. currentVersion = installationinfo.Build.Version
  1017. availableVersion = installationinfo.Runtime.AvailableVersion
  1018. }
  1019. res := &apiv1.InitResponse{
  1020. ShowFooter: api.cfg.ShowFooter,
  1021. ShowNavigation: api.cfg.ShowNavigation,
  1022. ShowNewVersions: showVersion && api.cfg.ShowNewVersions,
  1023. AvailableVersion: availableVersion,
  1024. CurrentVersion: currentVersion,
  1025. PageTitle: api.cfg.PageTitle,
  1026. SectionNavigationStyle: api.cfg.SectionNavigationStyle,
  1027. DefaultIconForBack: api.cfg.DefaultIconForBack,
  1028. EnableCustomJs: api.cfg.EnableCustomJs,
  1029. AuthLoginUrl: api.cfg.AuthLoginUrl,
  1030. AuthLocalLogin: api.cfg.AuthLocalUsers.Enabled,
  1031. OAuth2Providers: buildPublicOAuth2ProvidersList(api.cfg),
  1032. AdditionalLinks: buildAdditionalLinks(api.cfg.AdditionalNavigationLinks),
  1033. StyleMods: api.cfg.StyleMods,
  1034. RootDashboards: api.buildRootDashboards(user, api.cfg.Dashboards),
  1035. AuthenticatedUser: user.Username,
  1036. AuthenticatedUserProvider: user.Provider,
  1037. EffectivePolicy: buildEffectivePolicy(user.EffectivePolicy),
  1038. BannerMessage: api.cfg.BannerMessage,
  1039. BannerCss: api.cfg.BannerCSS,
  1040. ShowDiagnostics: user.EffectivePolicy.ShowDiagnostics,
  1041. ShowLogList: user.EffectivePolicy.ShowLogList,
  1042. LoginRequired: loginRequired,
  1043. AvailableThemes: discoverAvailableThemes(api.cfg),
  1044. ShowNavigateOnStartIcons: api.cfg.ShowNavigateOnStartIcons,
  1045. ConfigIssueCount: configIssueCountForUser(api, user),
  1046. }
  1047. return connect.NewResponse(res), nil
  1048. }
  1049. // discoverAvailableThemes finds all available themes in the custom-webui/themes directory.
  1050. // A theme is considered available if it has a theme.css file.
  1051. func discoverAvailableThemes(cfg *config.Config) []string {
  1052. configDir := cfg.GetDir()
  1053. if configDir == "" {
  1054. return []string{}
  1055. }
  1056. themesDir := path.Join(configDir, "custom-webui", "themes")
  1057. entries, err := os.ReadDir(themesDir)
  1058. if err != nil {
  1059. log.WithFields(log.Fields{
  1060. "themesDir": themesDir,
  1061. "error": err,
  1062. }).Tracef("Could not read themes directory")
  1063. return []string{}
  1064. }
  1065. themes := collectValidThemes(themesDir, entries)
  1066. sort.Strings(themes)
  1067. return themes
  1068. }
  1069. // collectValidThemes collects theme names from directory entries that have a theme.css file.
  1070. func collectValidThemes(themesDir string, entries []os.DirEntry) []string {
  1071. var themes []string
  1072. for _, entry := range entries {
  1073. if themeName := getValidThemeName(themesDir, entry); themeName != "" {
  1074. themes = append(themes, themeName)
  1075. }
  1076. }
  1077. return themes
  1078. }
  1079. // getValidThemeName returns the theme name if the entry is a valid theme directory with theme.css, otherwise returns empty string.
  1080. func getValidThemeName(themesDir string, entry os.DirEntry) string {
  1081. if !entry.IsDir() {
  1082. return ""
  1083. }
  1084. themeName := entry.Name()
  1085. themeCssPath := path.Join(themesDir, themeName, "theme.css")
  1086. if _, err := os.Stat(themeCssPath); err != nil {
  1087. return ""
  1088. }
  1089. return themeName
  1090. }
  1091. func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
  1092. var rootDashboards []string
  1093. dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
  1094. api.addDefaultDashboardIfNeeded(&rootDashboards, dashboardRenderRequest)
  1095. api.addCustomDashboards(&rootDashboards, dashboards, dashboardRenderRequest)
  1096. return rootDashboards
  1097. }
  1098. func (api *oliveTinAPI) addDefaultDashboardIfNeeded(rootDashboards *[]string, rr *DashboardRenderRequest) {
  1099. defaultDashboard := buildDefaultDashboard(rr)
  1100. if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 {
  1101. log.Tracef("defaultDashboard: %+v", defaultDashboard.Contents)
  1102. *rootDashboards = append(*rootDashboards, "Actions")
  1103. }
  1104. }
  1105. func (api *oliveTinAPI) addCustomDashboards(rootDashboards *[]string, dashboards []*config.DashboardComponent, rr *DashboardRenderRequest) {
  1106. for _, dashboard := range dashboards {
  1107. // We have to build the dashboard response instead of just looping over config.dashboards,
  1108. // because we need to check if the user has access to the dashboard
  1109. db := renderDashboard(rr, dashboard.Title)
  1110. if db != nil {
  1111. *rootDashboards = append(*rootDashboards, dashboard.Title)
  1112. }
  1113. }
  1114. }
  1115. func buildPublicOAuth2ProvidersList(cfg *config.Config) []*apiv1.OAuth2Provider {
  1116. var publicProviders []*apiv1.OAuth2Provider
  1117. for providerKey, provider := range cfg.AuthOAuth2Providers {
  1118. publicProviders = append(publicProviders, &apiv1.OAuth2Provider{
  1119. Title: provider.Title,
  1120. Icon: provider.Icon,
  1121. Key: providerKey,
  1122. })
  1123. }
  1124. sort.Slice(publicProviders, func(i, j int) bool {
  1125. return publicProviders[i].Key < publicProviders[j].Key
  1126. })
  1127. return publicProviders
  1128. }
  1129. func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLink {
  1130. var additionalLinks []*apiv1.AdditionalLink
  1131. for _, link := range links {
  1132. additionalLinks = append(additionalLinks, &apiv1.AdditionalLink{
  1133. Title: link.Title,
  1134. Url: link.Url,
  1135. })
  1136. }
  1137. return additionalLinks
  1138. }
  1139. func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
  1140. entry := api.getValidLogEntryForStreaming(executionTrackingId)
  1141. if entry == nil {
  1142. return
  1143. }
  1144. msg := &apiv1.EventStreamResponse{
  1145. Event: &apiv1.EventStreamResponse_OutputChunk{
  1146. OutputChunk: &apiv1.EventOutputChunk{
  1147. Output: string(content),
  1148. ExecutionTrackingId: executionTrackingId,
  1149. },
  1150. },
  1151. }
  1152. toRemove := []*streamingClient{}
  1153. for _, client := range api.copyOfStreamingClients() {
  1154. api.maybeSendOutputChunk(client, entry, msg, &toRemove)
  1155. }
  1156. for _, client := range toRemove {
  1157. api.removeClient(client)
  1158. }
  1159. }
  1160. func (api *oliveTinAPI) getValidLogEntryForStreaming(executionTrackingId string) *executor.InternalLogEntry {
  1161. entry, ok := api.executor.GetLog(executionTrackingId)
  1162. if !ok || !isValidLogEntry(entry) {
  1163. return nil
  1164. }
  1165. return entry
  1166. }
  1167. func (api *oliveTinAPI) maybeSendOutputChunk(client *streamingClient, entry *executor.InternalLogEntry, msg *apiv1.EventStreamResponse, toRemove *[]*streamingClient) {
  1168. if client == nil {
  1169. return
  1170. }
  1171. if !api.mayViewExecutionEvent(entry, client.AuthenticatedUser) {
  1172. return
  1173. }
  1174. if !api.trySendEventToClient(client, msg) {
  1175. *toRemove = append(*toRemove, client)
  1176. }
  1177. }
  1178. func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
  1179. user := auth.UserFromApiCall(ctx, req, api.cfg)
  1180. if err := api.checkDashboardAccess(user); err != nil {
  1181. return nil, err
  1182. }
  1183. entityMap := entities.GetEntities()
  1184. entityDefinitions := api.buildEntityDefinitionsResponse(req.Msg, entityMap)
  1185. res := &apiv1.GetEntitiesResponse{
  1186. EntityDefinitions: entityDefinitions,
  1187. }
  1188. return connect.NewResponse(res), nil
  1189. }
  1190. func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity, properties []config.EntityProperty) []*apiv1.Entity {
  1191. instanceKeys := make([]string, 0, len(entityInstances))
  1192. for key := range entityInstances {
  1193. instanceKeys = append(instanceKeys, key)
  1194. }
  1195. sort.Strings(instanceKeys)
  1196. instances := make([]*apiv1.Entity, 0, len(instanceKeys))
  1197. for _, key := range instanceKeys {
  1198. e := entityInstances[key]
  1199. instances = append(instances, &apiv1.Entity{
  1200. Title: e.Title,
  1201. UniqueKey: e.UniqueKey,
  1202. Type: entityType,
  1203. Fields: entityListFields(e.Data, properties),
  1204. })
  1205. }
  1206. return instances
  1207. }
  1208. func findDashboardsForEntity(entityTitle string, dashboards []*config.DashboardComponent) []string {
  1209. var foundDashboards []string
  1210. seen := make(map[string]bool)
  1211. findEntityInComponents(entityTitle, "", dashboards, &foundDashboards, seen)
  1212. return foundDashboards
  1213. }
  1214. func findEntityInComponents(entityTitle string, parentTitle string, components []*config.DashboardComponent, foundDashboards *[]string, seen map[string]bool) {
  1215. for _, component := range components {
  1216. if component.Entity == entityTitle {
  1217. addEntityDashboard(component, parentTitle, foundDashboards, seen)
  1218. }
  1219. if len(component.Contents) > 0 {
  1220. findEntityInComponents(entityTitle, component.Title, component.Contents, foundDashboards, seen)
  1221. }
  1222. }
  1223. }
  1224. func addEntityDashboard(component *config.DashboardComponent, parentTitle string, foundDashboards *[]string, seen map[string]bool) {
  1225. if component.Type == "directory" {
  1226. addEntityDirectory(component, foundDashboards, seen)
  1227. } else {
  1228. addParentDashboard(parentTitle, foundDashboards, seen)
  1229. }
  1230. }
  1231. func addEntityDirectory(component *config.DashboardComponent, foundDashboards *[]string, seen map[string]bool) {
  1232. dashboardTitle := component.Title + " [Entity Directory]"
  1233. if !seen[dashboardTitle] {
  1234. *foundDashboards = append(*foundDashboards, dashboardTitle)
  1235. seen[dashboardTitle] = true
  1236. seen[component.Title] = true
  1237. }
  1238. }
  1239. func addParentDashboard(parentTitle string, foundDashboards *[]string, seen map[string]bool) {
  1240. if parentTitle != "" && !seen[parentTitle] {
  1241. *foundDashboards = append(*foundDashboards, parentTitle)
  1242. seen[parentTitle] = true
  1243. }
  1244. }
  1245. func findDirectoriesInEntityFieldsets(entityType string, dashboards []*config.DashboardComponent) []string {
  1246. var directories []string
  1247. for _, dashboard := range dashboards {
  1248. findDirectoriesInEntityFieldsetsRecursive(entityType, dashboard, &directories)
  1249. }
  1250. return directories
  1251. }
  1252. func findDirectoriesInEntityFieldsetsRecursive(entityType string, component *config.DashboardComponent, directories *[]string) {
  1253. if component.Entity == entityType {
  1254. collectDirectoriesFromComponent(component, directories)
  1255. }
  1256. if len(component.Contents) > 0 {
  1257. searchSubcomponentsForDirectories(entityType, component.Contents, directories)
  1258. }
  1259. }
  1260. func collectDirectoriesFromComponent(component *config.DashboardComponent, directories *[]string) {
  1261. for _, subitem := range component.Contents {
  1262. if subitem.Type == "directory" {
  1263. *directories = append(*directories, subitem.Title)
  1264. }
  1265. }
  1266. }
  1267. func searchSubcomponentsForDirectories(entityType string, contents []*config.DashboardComponent, directories *[]string) {
  1268. for _, subitem := range contents {
  1269. findDirectoriesInEntityFieldsetsRecursive(entityType, subitem, directories)
  1270. }
  1271. }
  1272. func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) {
  1273. user := auth.UserFromApiCall(ctx, req, api.cfg)
  1274. if err := api.checkDashboardAccess(user); err != nil {
  1275. return nil, err
  1276. }
  1277. instances := entities.GetEntityInstances(req.Msg.Type)
  1278. if len(instances) == 0 {
  1279. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type))
  1280. }
  1281. entity, ok := instances[req.Msg.UniqueKey]
  1282. if !ok {
  1283. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
  1284. }
  1285. res := buildEntityResponse(entity, req.Msg.Type, api.cfg)
  1286. res.RelatedActions = api.relatedActionsForEntity(user, req.Msg.Type, entity)
  1287. return connect.NewResponse(res), nil
  1288. }
  1289. func entityTypeIcon(cfg *config.Config, entityType string) string {
  1290. entityFile := entityFileForType(cfg, entityType)
  1291. if entityFile == nil {
  1292. return ""
  1293. }
  1294. return entityFile.Icon
  1295. }
  1296. func entityFileForType(cfg *config.Config, entityType string) *config.EntityFile {
  1297. for _, entityFile := range cfg.Entities {
  1298. if entityFile != nil && entityFile.Name == entityType {
  1299. return entityFile
  1300. }
  1301. }
  1302. return nil
  1303. }
  1304. func entityPropertiesFromFile(entityFile *config.EntityFile) []config.EntityProperty {
  1305. if entityFile == nil {
  1306. return nil
  1307. }
  1308. return entityFile.Properties
  1309. }
  1310. func entityDefinitionProperties(properties []config.EntityProperty) []*apiv1.EntityProperty {
  1311. if len(properties) == 0 {
  1312. return nil
  1313. }
  1314. result := make([]*apiv1.EntityProperty, 0, len(properties))
  1315. for _, property := range properties {
  1316. result = append(result, &apiv1.EntityProperty{
  1317. Name: property.Name,
  1318. Title: property.Title,
  1319. })
  1320. }
  1321. return result
  1322. }
  1323. func entityListFields(data any, properties []config.EntityProperty) map[string]string {
  1324. if len(properties) == 0 {
  1325. return nil
  1326. }
  1327. fields := make(map[string]string, len(properties))
  1328. for _, property := range properties {
  1329. fields[property.Name] = entityPropertyValue(data, property.Name)
  1330. }
  1331. return fields
  1332. }
  1333. func entityPropertyValue(data any, propertyName string) string {
  1334. dataMap, ok := data.(map[string]any)
  1335. if !ok {
  1336. return ""
  1337. }
  1338. if value, found := dataMap[propertyName]; found {
  1339. return fmt.Sprintf("%v", value)
  1340. }
  1341. return entityPropertyValueCaseInsensitive(dataMap, propertyName)
  1342. }
  1343. func entityPropertyValueCaseInsensitive(dataMap map[string]any, propertyName string) string {
  1344. propertyNameLower := strings.ToLower(propertyName)
  1345. for key, value := range dataMap {
  1346. if strings.ToLower(key) == propertyNameLower {
  1347. return fmt.Sprintf("%v", value)
  1348. }
  1349. }
  1350. return ""
  1351. }
  1352. func buildEntityResponse(entity *entities.Entity, entityType string, cfg *config.Config) *apiv1.Entity {
  1353. properties := entityPropertiesFromFile(entityFileForType(cfg, entityType))
  1354. res := &apiv1.Entity{
  1355. Title: entity.Title,
  1356. UniqueKey: entity.UniqueKey,
  1357. Type: entityType,
  1358. Directories: findDirectoriesInEntityFieldsets(entityType, cfg.Dashboards),
  1359. Fields: entityFieldsForResponse(entity.Data, properties),
  1360. Icon: entityTypeIcon(cfg, entityType),
  1361. }
  1362. return res
  1363. }
  1364. func serializeEntityFields(data any) map[string]string {
  1365. if data == nil {
  1366. return nil
  1367. }
  1368. dataMap, ok := data.(map[string]any)
  1369. if !ok {
  1370. return nil
  1371. }
  1372. fields := make(map[string]string)
  1373. for k, v := range dataMap {
  1374. fields[k] = fmt.Sprintf("%v", v)
  1375. }
  1376. return fields
  1377. }
  1378. func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
  1379. execReqLogEntry, err := api.restartActionLogEntry(req.Msg.ExecutionTrackingId)
  1380. if err != nil {
  1381. return nil, err
  1382. }
  1383. if err := validateRestartLogEntry(execReqLogEntry); err != nil {
  1384. return nil, err
  1385. }
  1386. authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
  1387. execReq := executor.ExecutionRequest{
  1388. Binding: execReqLogEntry.Binding,
  1389. Arguments: copyStringMap(execReqLogEntry.Arguments),
  1390. Justification: execReqLogEntry.Justification,
  1391. AuthenticatedUser: authenticatedUser,
  1392. Cfg: api.cfg,
  1393. }
  1394. api.executor.ExecRequest(&execReq)
  1395. return connect.NewResponse(&apiv1.StartActionResponse{
  1396. ExecutionTrackingId: execReq.TrackingID,
  1397. }), nil
  1398. }
  1399. func (api *oliveTinAPI) restartActionLogEntry(executionTrackingId string) (*executor.InternalLogEntry, error) {
  1400. execReqLogEntry, found := api.executor.GetLog(executionTrackingId)
  1401. if !found {
  1402. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", executionTrackingId))
  1403. }
  1404. if execReqLogEntry.Binding == nil {
  1405. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", executionTrackingId))
  1406. }
  1407. if execReqLogEntry.Binding.Action == nil {
  1408. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", executionTrackingId))
  1409. }
  1410. return execReqLogEntry, nil
  1411. }
  1412. var (
  1413. executorListenersMu sync.Mutex
  1414. executorListeners = map[*executor.Executor]*oliveTinAPI{}
  1415. )
  1416. // RegisterExecutorListener registers the API server as an executor listener during startup.
  1417. // Call this before background goroutines that may trigger RebuildActionMap.
  1418. func RegisterExecutorListener(ex *executor.Executor) {
  1419. ensureExecutorListener(ex)
  1420. }
  1421. func ensureExecutorListener(ex *executor.Executor) *oliveTinAPI {
  1422. executorListenersMu.Lock()
  1423. defer executorListenersMu.Unlock()
  1424. if server, ok := executorListeners[ex]; ok {
  1425. return server
  1426. }
  1427. server := newServer(ex)
  1428. executorListeners[ex] = server
  1429. return server
  1430. }
  1431. func newServer(ex *executor.Executor) *oliveTinAPI {
  1432. server := &oliveTinAPI{
  1433. cfg: ex.Cfg,
  1434. executor: ex,
  1435. streamingClients: make(map[*streamingClient]struct{}),
  1436. }
  1437. ex.AddListener(server)
  1438. return server
  1439. }
  1440. func GetNewHandler(ex *executor.Executor) (string, http.Handler) {
  1441. server := ensureExecutorListener(ex)
  1442. jsonOpt := connectproto.WithJSON(
  1443. protojson.MarshalOptions{
  1444. EmitUnpopulated: true, // https://github.com/OliveTin/OliveTin/issues/674
  1445. },
  1446. protojson.UnmarshalOptions{
  1447. DiscardUnknown: true,
  1448. },
  1449. )
  1450. return apiv1connect.NewOliveTinApiServiceHandler(server, jsonOpt)
  1451. }