api.go 58 KB

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