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. heartbeatStopOnce sync.Once
  57. heartbeatStop chan struct{}
  58. heartbeatDone chan struct{}
  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 := start + size
  685. if end > total {
  686. end = total
  687. }
  688. return pageInfo{total: total, size: size, start: start, end: end, empty: false}
  689. }
  690. /*
  691. This function is ONLY a helper for the UI - the arguments are validated properly
  692. on the StartAction -> Executor chain. This is here basically to provide helpful
  693. error messages more quickly before starting the action.
  694. It uses the same validation logic as the executor, including mangling argument
  695. values (e.g., datetime formatting, checkbox title-to-value conversion).
  696. */
  697. func (api *oliveTinAPI) argumentNotFoundForValidation(msg *apiv1.ValidateArgumentTypeRequest) bool {
  698. if msg.BindingId == "" || msg.ArgumentName == "" {
  699. return false
  700. }
  701. arg, _ := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
  702. return arg == nil
  703. }
  704. func (api *oliveTinAPI) validateArgumentTypeBindingAccess(user *authpublic.AuthenticatedUser, msg *apiv1.ValidateArgumentTypeRequest) error {
  705. if msg == nil || msg.BindingId == "" {
  706. return nil
  707. }
  708. return api.errUnlessUserMayValidateArgumentTypeForBinding(user, msg.BindingId)
  709. }
  710. func (api *oliveTinAPI) errUnlessUserMayValidateArgumentTypeForBinding(user *authpublic.AuthenticatedUser, bindingID string) error {
  711. binding := api.executor.FindBindingByID(bindingID)
  712. if binding == nil || binding.Action == nil {
  713. return connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", bindingID))
  714. }
  715. if !api.userCanViewAction(user, binding.Action) {
  716. return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
  717. }
  718. return nil
  719. }
  720. func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
  721. user := auth.UserFromApiCall(ctx, req, api.cfg)
  722. if err := api.checkDashboardAccess(user); err != nil {
  723. return nil, err
  724. }
  725. if err := api.validateArgumentTypeBindingAccess(user, req.Msg); err != nil {
  726. return nil, err
  727. }
  728. if api.argumentNotFoundForValidation(req.Msg) {
  729. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId))
  730. }
  731. return api.validateArgumentTypeConnectResponse(req.Msg)
  732. }
  733. func (api *oliveTinAPI) validateArgumentTypeConnectResponse(msg *apiv1.ValidateArgumentTypeRequest) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
  734. err := api.validateArgumentTypeInternal(msg)
  735. desc := ""
  736. if err != nil {
  737. desc = err.Error()
  738. }
  739. return connect.NewResponse(&apiv1.ValidateArgumentTypeResponse{
  740. Valid: err == nil,
  741. Description: desc,
  742. }), nil
  743. }
  744. func (api *oliveTinAPI) validateArgumentTypeInternal(msg *apiv1.ValidateArgumentTypeRequest) error {
  745. if msg.BindingId == "" || msg.ArgumentName == "" {
  746. return executor.TypeSafetyCheck("", msg.Value, msg.Type)
  747. }
  748. arg, action := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
  749. if arg == nil {
  750. return fmt.Errorf("argument not found")
  751. }
  752. return executor.ValidateArgument(arg, msg.Value, action)
  753. }
  754. func (api *oliveTinAPI) findArgumentForValidation(bindingId string, argumentName string) (*config.ActionArgument, *config.Action) {
  755. binding := api.executor.FindBindingByID(bindingId)
  756. if binding == nil || binding.Action == nil {
  757. return nil, nil
  758. }
  759. arg := api.findArgumentByName(binding.Action, argumentName)
  760. return arg, binding.Action
  761. }
  762. func (api *oliveTinAPI) findArgumentByName(action *config.Action, name string) *config.ActionArgument {
  763. for i := range action.Arguments {
  764. if action.Arguments[i].Name == name {
  765. return &action.Arguments[i]
  766. }
  767. }
  768. return nil
  769. }
  770. func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) {
  771. user := auth.UserFromApiCall(ctx, req, api.cfg)
  772. if err := api.checkDashboardAccess(user); err != nil {
  773. return nil, err
  774. }
  775. res := &apiv1.WhoAmIResponse{
  776. AuthenticatedUser: user.Username,
  777. Usergroup: user.UsergroupLine,
  778. Provider: user.Provider,
  779. Sid: user.SID,
  780. Acls: user.Acls,
  781. }
  782. return connect.NewResponse(res), nil
  783. }
  784. func (api *oliveTinAPI) ServerDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.ServerDiagnosticsRequest]) (*connect.Response[apiv1.ServerDiagnosticsResponse], error) {
  785. user := auth.UserFromApiCall(ctx, req, api.cfg)
  786. redactVersion := !user.EffectivePolicy.ShowVersionNumber
  787. diagnostics := installationinfo.GetServerDiagnostics(redactVersion)
  788. if !api.cfg.InsecureAllowDumpServerDiagnostics {
  789. log.Info(diagnostics)
  790. 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."
  791. }
  792. ret := &apiv1.ServerDiagnosticsResponse{
  793. Alert: diagnostics,
  794. }
  795. return connect.NewResponse(ret), nil
  796. }
  797. func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.DumpVarsRequest]) (*connect.Response[apiv1.DumpVarsResponse], error) {
  798. res := &apiv1.DumpVarsResponse{}
  799. if !api.cfg.InsecureAllowDumpVars {
  800. res.Alert = "Dumping variables is not allowed by default because it is insecure."
  801. return connect.NewResponse(res), nil
  802. }
  803. jsonstring, err := json.MarshalIndent(tpl.GetNewGeneralTemplateContext(), "", " ")
  804. if err != nil {
  805. log.WithError(err).Error("DumpVars: failed to marshal template context from GetNewGeneralTemplateContext")
  806. return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("dump vars: marshal template context: %w", err))
  807. }
  808. fmt.Printf("%s", jsonstring)
  809. res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpVars = false again after you don't need it anymore"
  810. return connect.NewResponse(res), nil
  811. }
  812. func debugBindingActionTitle(binding *executor.ActionBinding) string {
  813. if binding == nil || binding.Action == nil {
  814. return ""
  815. }
  816. return binding.Action.Title
  817. }
  818. func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Request[apiv1.DumpPublicIdActionMapRequest]) (*connect.Response[apiv1.DumpPublicIdActionMapResponse], error) {
  819. res := &apiv1.DumpPublicIdActionMapResponse{}
  820. res.Contents = make(map[string]*apiv1.DebugBinding)
  821. if !api.cfg.InsecureAllowDumpActionMap {
  822. res.Alert = "Dumping Public IDs is disallowed."
  823. return connect.NewResponse(res), nil
  824. }
  825. api.executor.MapActionBindingsLock.RLock()
  826. for k, v := range api.executor.MapActionBindings {
  827. res.Contents[k] = &apiv1.DebugBinding{
  828. ActionTitle: debugBindingActionTitle(v),
  829. }
  830. }
  831. api.executor.MapActionBindingsLock.RUnlock()
  832. res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
  833. return connect.NewResponse(res), nil
  834. }
  835. func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.GetReadyzRequest]) (*connect.Response[apiv1.GetReadyzResponse], error) {
  836. res := &apiv1.GetReadyzResponse{
  837. Status: "OK",
  838. }
  839. return connect.NewResponse(res), nil
  840. }
  841. func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error {
  842. log.Debugf("EventStream: %v", req.Msg)
  843. // Set X-Accel-Buffering header to disable nginx buffering for this stream
  844. // https://github.com/OliveTin/OliveTin/issues/765
  845. srv.ResponseHeader().Set("X-Accel-Buffering", "no")
  846. user := auth.UserFromApiCall(ctx, req, api.cfg)
  847. if err := api.checkDashboardAccess(user); err != nil {
  848. return err
  849. }
  850. client := &streamingClient{
  851. channel: make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
  852. AuthenticatedUser: user,
  853. heartbeatStop: make(chan struct{}),
  854. heartbeatDone: make(chan struct{}),
  855. }
  856. if err := api.registerStreamingClient(client); err != nil {
  857. return connect.NewError(connect.CodeResourceExhausted, err)
  858. }
  859. log.WithFields(log.Fields{
  860. "authenticatedUser": user.Username,
  861. }).Debugf("EventStream: client connected")
  862. go api.sendEventStreamHeartbeats(client)
  863. // loop over client channel and send events to connectedClient
  864. for msg := range client.channel {
  865. log.Debugf("Sending event to client: %v", msg)
  866. if err := srv.Send(msg); err != nil {
  867. log.Errorf("Error sending event to client: %v", err)
  868. // Remove disconnected client from the list
  869. api.removeClient(client)
  870. break
  871. }
  872. }
  873. log.Infof("EventStream: client disconnected")
  874. return nil
  875. }
  876. func (api *oliveTinAPI) registerStreamingClient(client *streamingClient) error {
  877. api.streamingClientsMutex.Lock()
  878. defer api.streamingClientsMutex.Unlock()
  879. if len(api.streamingClients) >= maxEventStreamClients {
  880. log.WithFields(log.Fields{
  881. "limit": maxEventStreamClients,
  882. }).Warn("EventStream: rejecting client; concurrent client limit reached")
  883. return errEventStreamClientLimit
  884. }
  885. api.streamingClients[client] = struct{}{}
  886. return nil
  887. }
  888. func (api *oliveTinAPI) sendEventStreamHeartbeats(client *streamingClient) {
  889. defer close(client.heartbeatDone)
  890. if !api.sendEventStreamHeartbeat(client) {
  891. go api.removeClient(client)
  892. return
  893. }
  894. ticker := time.NewTicker(10 * time.Second)
  895. defer ticker.Stop()
  896. api.runEventStreamHeartbeatLoop(client, ticker)
  897. }
  898. func (api *oliveTinAPI) runEventStreamHeartbeatLoop(client *streamingClient, ticker *time.Ticker) {
  899. for {
  900. if api.waitEventStreamHeartbeatOrDone(client.heartbeatStop, ticker) {
  901. return
  902. }
  903. if !api.sendEventStreamHeartbeat(client) {
  904. go api.removeClient(client)
  905. return
  906. }
  907. }
  908. }
  909. func (api *oliveTinAPI) waitEventStreamHeartbeatOrDone(done <-chan struct{}, ticker *time.Ticker) bool {
  910. select {
  911. case <-done:
  912. return true
  913. case <-ticker.C:
  914. return false
  915. }
  916. }
  917. func (api *oliveTinAPI) sendEventStreamHeartbeat(client *streamingClient) bool {
  918. msg := &apiv1.EventStreamResponse{
  919. Event: &apiv1.EventStreamResponse_Heartbeat{
  920. Heartbeat: &apiv1.EventHeartbeat{},
  921. },
  922. }
  923. return api.trySendEventToClient(client, msg)
  924. }
  925. func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) {
  926. if clientToRemove == nil {
  927. return
  928. }
  929. api.streamingClientsMutex.Lock()
  930. if _, exists := api.streamingClients[clientToRemove]; !exists {
  931. api.streamingClientsMutex.Unlock()
  932. return
  933. }
  934. delete(api.streamingClients, clientToRemove)
  935. api.streamingClientsMutex.Unlock()
  936. clientToRemove.stopHeartbeat()
  937. close(clientToRemove.channel)
  938. }
  939. func (api *oliveTinAPI) OnActionMapRebuilt() {
  940. toRemove := []*streamingClient{}
  941. for _, client := range api.copyOfStreamingClients() {
  942. msg := &apiv1.EventStreamResponse{
  943. Event: &apiv1.EventStreamResponse_ConfigChanged{
  944. ConfigChanged: &apiv1.EventConfigChanged{},
  945. },
  946. }
  947. if !api.trySendEventToClient(client, msg) {
  948. toRemove = append(toRemove, client)
  949. }
  950. }
  951. for _, client := range toRemove {
  952. api.removeClient(client)
  953. }
  954. }
  955. func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
  956. toRemove := []*streamingClient{}
  957. for _, client := range api.copyOfStreamingClients() {
  958. api.maybeSendExecutionStarted(client, ex, &toRemove)
  959. }
  960. for _, client := range toRemove {
  961. api.removeClient(client)
  962. }
  963. }
  964. func (api *oliveTinAPI) maybeSendExecutionStarted(client *streamingClient, ex *executor.InternalLogEntry, toRemove *[]*streamingClient) {
  965. if client == nil {
  966. return
  967. }
  968. if !api.mayViewExecutionEvent(ex, client.AuthenticatedUser) {
  969. return
  970. }
  971. msg := &apiv1.EventStreamResponse{
  972. Event: &apiv1.EventStreamResponse_ExecutionStarted{
  973. ExecutionStarted: &apiv1.EventExecutionStarted{
  974. LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
  975. },
  976. },
  977. }
  978. if !api.trySendEventToClient(client, msg) {
  979. *toRemove = append(*toRemove, client)
  980. }
  981. }
  982. func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) {
  983. toRemove := []*streamingClient{}
  984. for _, client := range api.copyOfStreamingClients() {
  985. api.maybeSendExecutionFinished(client, ile, &toRemove)
  986. }
  987. for _, client := range toRemove {
  988. api.removeClient(client)
  989. }
  990. }
  991. func (api *oliveTinAPI) maybeSendExecutionFinished(client *streamingClient, ile *executor.InternalLogEntry, toRemove *[]*streamingClient) {
  992. if client == nil {
  993. return
  994. }
  995. if !api.mayViewExecutionEvent(ile, client.AuthenticatedUser) {
  996. return
  997. }
  998. msg := &apiv1.EventStreamResponse{
  999. Event: &apiv1.EventStreamResponse_ExecutionFinished{
  1000. ExecutionFinished: &apiv1.EventExecutionFinished{
  1001. LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser),
  1002. },
  1003. },
  1004. }
  1005. if !api.trySendEventToClient(client, msg) {
  1006. *toRemove = append(*toRemove, client)
  1007. }
  1008. }
  1009. func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) {
  1010. user := auth.UserFromApiCall(ctx, req, api.cfg)
  1011. if err := api.checkDashboardAccess(user); err != nil {
  1012. return nil, err
  1013. }
  1014. if !user.EffectivePolicy.ShowDiagnostics {
  1015. return nil, connect.NewError(connect.CodePermissionDenied, fmt.Errorf("diagnostics are not available for your account"))
  1016. }
  1017. res := &apiv1.GetDiagnosticsResponse{
  1018. SshFoundKey: installationinfo.Runtime.SshFoundKey,
  1019. SshFoundConfig: installationinfo.Runtime.SshFoundConfig,
  1020. ConfigIssues: api.buildConfigIssuesForUser(user),
  1021. }
  1022. return connect.NewResponse(res), nil
  1023. }
  1024. func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
  1025. user := auth.UserFromApiCall(ctx, req, api.cfg)
  1026. loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
  1027. showVersion := user.EffectivePolicy.ShowVersionNumber
  1028. currentVersion := ""
  1029. availableVersion := ""
  1030. if showVersion {
  1031. currentVersion = installationinfo.Build.Version
  1032. availableVersion = installationinfo.Runtime.AvailableVersion
  1033. }
  1034. rootDashboardEntries := api.buildRootDashboardEntries(user, api.cfg.Dashboards)
  1035. res := &apiv1.InitResponse{
  1036. ShowFooter: api.cfg.ShowFooter,
  1037. ShowNavigation: api.cfg.ShowNavigation,
  1038. ShowNewVersions: showVersion && api.cfg.ShowNewVersions,
  1039. AvailableVersion: availableVersion,
  1040. CurrentVersion: currentVersion,
  1041. PageTitle: api.cfg.PageTitle,
  1042. SectionNavigationStyle: api.cfg.SectionNavigationStyle,
  1043. DefaultIconForBack: api.cfg.DefaultIconForBack,
  1044. EnableCustomJs: api.cfg.EnableCustomJs,
  1045. AuthLoginUrl: api.cfg.AuthLoginUrl,
  1046. AuthLocalLogin: api.cfg.AuthLocalUsers.Enabled,
  1047. OAuth2Providers: buildPublicOAuth2ProvidersList(api.cfg),
  1048. AdditionalLinks: buildAdditionalLinks(api.cfg.AdditionalNavigationLinks),
  1049. StyleMods: api.cfg.StyleMods,
  1050. RootDashboards: rootDashboardTitles(rootDashboardEntries),
  1051. RootDashboardEntries: rootDashboardEntries,
  1052. AuthenticatedUser: user.Username,
  1053. AuthenticatedUserProvider: user.Provider,
  1054. EffectivePolicy: buildEffectivePolicy(user.EffectivePolicy),
  1055. BannerMessage: api.cfg.BannerMessage,
  1056. BannerCss: api.cfg.BannerCSS,
  1057. ShowDiagnostics: user.EffectivePolicy.ShowDiagnostics,
  1058. ShowLogList: user.EffectivePolicy.ShowLogList,
  1059. LoginRequired: loginRequired,
  1060. AvailableThemes: discoverAvailableThemes(api.cfg),
  1061. ShowNavigateOnStartIcons: api.cfg.ShowNavigateOnStartIcons,
  1062. ConfigIssueCount: configIssueCountForUser(api, user),
  1063. }
  1064. return connect.NewResponse(res), nil
  1065. }
  1066. // discoverAvailableThemes finds all available themes in the custom-webui/themes directory.
  1067. // A theme is considered available if it has a theme.css file.
  1068. func discoverAvailableThemes(cfg *config.Config) []string {
  1069. configDir := cfg.GetDir()
  1070. if configDir == "" {
  1071. return []string{}
  1072. }
  1073. themesDir := path.Join(configDir, "custom-webui", "themes")
  1074. entries, err := os.ReadDir(themesDir)
  1075. if err != nil {
  1076. log.WithFields(log.Fields{
  1077. "themesDir": themesDir,
  1078. "error": err,
  1079. }).Tracef("Could not read themes directory")
  1080. return []string{}
  1081. }
  1082. themes := collectValidThemes(themesDir, entries)
  1083. sort.Strings(themes)
  1084. return themes
  1085. }
  1086. // collectValidThemes collects theme names from directory entries that have a theme.css file.
  1087. func collectValidThemes(themesDir string, entries []os.DirEntry) []string {
  1088. var themes []string
  1089. for _, entry := range entries {
  1090. if themeName := getValidThemeName(themesDir, entry); themeName != "" {
  1091. themes = append(themes, themeName)
  1092. }
  1093. }
  1094. return themes
  1095. }
  1096. // getValidThemeName returns the theme name if the entry is a valid theme directory with theme.css, otherwise returns empty string.
  1097. func getValidThemeName(themesDir string, entry os.DirEntry) string {
  1098. if !entry.IsDir() {
  1099. return ""
  1100. }
  1101. themeName := entry.Name()
  1102. themeCssPath := path.Join(themesDir, themeName, "theme.css")
  1103. if _, err := os.Stat(themeCssPath); err != nil {
  1104. return ""
  1105. }
  1106. return themeName
  1107. }
  1108. func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
  1109. return rootDashboardTitles(api.buildRootDashboardEntries(user, dashboards))
  1110. }
  1111. func rootDashboardTitles(entries []*apiv1.RootDashboard) []string {
  1112. titles := make([]string, 0, len(entries))
  1113. for _, entry := range entries {
  1114. titles = append(titles, entry.Title)
  1115. }
  1116. return titles
  1117. }
  1118. func (api *oliveTinAPI) buildRootDashboardEntries(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []*apiv1.RootDashboard {
  1119. var entries []*apiv1.RootDashboard
  1120. dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
  1121. api.addDefaultDashboardEntryIfNeeded(&entries, dashboardRenderRequest)
  1122. api.addCustomDashboardEntries(&entries, dashboards, dashboardRenderRequest)
  1123. return entries
  1124. }
  1125. func (api *oliveTinAPI) addDefaultDashboardEntryIfNeeded(entries *[]*apiv1.RootDashboard, rr *DashboardRenderRequest) {
  1126. defaultDashboard := buildDefaultDashboard(rr)
  1127. if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 {
  1128. log.Tracef("defaultDashboard: %+v", defaultDashboard.Contents)
  1129. *entries = append(*entries, &apiv1.RootDashboard{Title: "Actions"})
  1130. }
  1131. }
  1132. func (api *oliveTinAPI) addCustomDashboardEntries(entries *[]*apiv1.RootDashboard, dashboards []*config.DashboardComponent, rr *DashboardRenderRequest) {
  1133. for _, dashboard := range dashboards {
  1134. // We have to build the dashboard response instead of just looping over config.dashboards,
  1135. // because we need to check if the user has access to the dashboard
  1136. db := renderDashboard(rr, dashboard.Title)
  1137. if db != nil {
  1138. *entries = append(*entries, &apiv1.RootDashboard{
  1139. Title: dashboard.Title,
  1140. Category: dashboard.Category,
  1141. })
  1142. }
  1143. }
  1144. }
  1145. func buildPublicOAuth2ProvidersList(cfg *config.Config) []*apiv1.OAuth2Provider {
  1146. var publicProviders []*apiv1.OAuth2Provider
  1147. for providerKey, provider := range cfg.AuthOAuth2Providers {
  1148. publicProviders = append(publicProviders, &apiv1.OAuth2Provider{
  1149. Title: provider.Title,
  1150. Icon: provider.Icon,
  1151. Key: providerKey,
  1152. })
  1153. }
  1154. sort.Slice(publicProviders, func(i, j int) bool {
  1155. return publicProviders[i].Key < publicProviders[j].Key
  1156. })
  1157. return publicProviders
  1158. }
  1159. func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLink {
  1160. var additionalLinks []*apiv1.AdditionalLink
  1161. for _, link := range links {
  1162. additionalLinks = append(additionalLinks, &apiv1.AdditionalLink{
  1163. Title: link.Title,
  1164. Url: link.Url,
  1165. })
  1166. }
  1167. return additionalLinks
  1168. }
  1169. func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
  1170. entry := api.getValidLogEntryForStreaming(executionTrackingId)
  1171. if entry == nil {
  1172. return
  1173. }
  1174. msg := &apiv1.EventStreamResponse{
  1175. Event: &apiv1.EventStreamResponse_OutputChunk{
  1176. OutputChunk: &apiv1.EventOutputChunk{
  1177. Output: string(content),
  1178. ExecutionTrackingId: executionTrackingId,
  1179. },
  1180. },
  1181. }
  1182. toRemove := []*streamingClient{}
  1183. for _, client := range api.copyOfStreamingClients() {
  1184. api.maybeSendOutputChunk(client, entry, msg, &toRemove)
  1185. }
  1186. for _, client := range toRemove {
  1187. api.removeClient(client)
  1188. }
  1189. }
  1190. func (api *oliveTinAPI) getValidLogEntryForStreaming(executionTrackingId string) *executor.InternalLogEntry {
  1191. entry, ok := api.executor.GetLog(executionTrackingId)
  1192. if !ok || !isValidLogEntry(entry) {
  1193. return nil
  1194. }
  1195. return entry
  1196. }
  1197. func (api *oliveTinAPI) maybeSendOutputChunk(client *streamingClient, entry *executor.InternalLogEntry, msg *apiv1.EventStreamResponse, toRemove *[]*streamingClient) {
  1198. if client == nil {
  1199. return
  1200. }
  1201. if !api.mayViewExecutionEvent(entry, client.AuthenticatedUser) {
  1202. return
  1203. }
  1204. if !api.trySendEventToClient(client, msg) {
  1205. *toRemove = append(*toRemove, client)
  1206. }
  1207. }
  1208. func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
  1209. user := auth.UserFromApiCall(ctx, req, api.cfg)
  1210. if err := api.checkDashboardAccess(user); err != nil {
  1211. return nil, err
  1212. }
  1213. entityMap := entities.GetEntities()
  1214. entityDefinitions := api.buildEntityDefinitionsResponse(req.Msg, entityMap)
  1215. res := &apiv1.GetEntitiesResponse{
  1216. EntityDefinitions: entityDefinitions,
  1217. }
  1218. return connect.NewResponse(res), nil
  1219. }
  1220. func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity, properties []config.EntityProperty) []*apiv1.Entity {
  1221. instanceKeys := make([]string, 0, len(entityInstances))
  1222. for key := range entityInstances {
  1223. instanceKeys = append(instanceKeys, key)
  1224. }
  1225. sort.Strings(instanceKeys)
  1226. instances := make([]*apiv1.Entity, 0, len(instanceKeys))
  1227. for _, key := range instanceKeys {
  1228. e := entityInstances[key]
  1229. instances = append(instances, &apiv1.Entity{
  1230. Title: e.Title,
  1231. UniqueKey: e.UniqueKey,
  1232. Type: entityType,
  1233. Fields: entityListFields(e.Data, properties),
  1234. })
  1235. }
  1236. return instances
  1237. }
  1238. func findDashboardsForEntity(entityTitle string, dashboards []*config.DashboardComponent) []string {
  1239. var foundDashboards []string
  1240. seen := make(map[string]bool)
  1241. findEntityInComponents(entityTitle, "", dashboards, &foundDashboards, seen)
  1242. return foundDashboards
  1243. }
  1244. func findEntityInComponents(entityTitle string, parentTitle string, components []*config.DashboardComponent, foundDashboards *[]string, seen map[string]bool) {
  1245. for _, component := range components {
  1246. if component.Entity == entityTitle {
  1247. addEntityDashboard(component, parentTitle, foundDashboards, seen)
  1248. }
  1249. if len(component.Contents) > 0 {
  1250. findEntityInComponents(entityTitle, component.Title, component.Contents, foundDashboards, seen)
  1251. }
  1252. }
  1253. }
  1254. func addEntityDashboard(component *config.DashboardComponent, parentTitle string, foundDashboards *[]string, seen map[string]bool) {
  1255. if component.Type == "directory" {
  1256. addEntityDirectory(component, foundDashboards, seen)
  1257. } else {
  1258. addParentDashboard(parentTitle, foundDashboards, seen)
  1259. }
  1260. }
  1261. func addEntityDirectory(component *config.DashboardComponent, foundDashboards *[]string, seen map[string]bool) {
  1262. dashboardTitle := component.Title + " [Entity Directory]"
  1263. if !seen[dashboardTitle] {
  1264. *foundDashboards = append(*foundDashboards, dashboardTitle)
  1265. seen[dashboardTitle] = true
  1266. seen[component.Title] = true
  1267. }
  1268. }
  1269. func addParentDashboard(parentTitle string, foundDashboards *[]string, seen map[string]bool) {
  1270. if parentTitle != "" && !seen[parentTitle] {
  1271. *foundDashboards = append(*foundDashboards, parentTitle)
  1272. seen[parentTitle] = true
  1273. }
  1274. }
  1275. func findDirectoriesInEntityFieldsets(entityType string, dashboards []*config.DashboardComponent) []string {
  1276. var directories []string
  1277. for _, dashboard := range dashboards {
  1278. findDirectoriesInEntityFieldsetsRecursive(entityType, dashboard, &directories)
  1279. }
  1280. return directories
  1281. }
  1282. func findDirectoriesInEntityFieldsetsRecursive(entityType string, component *config.DashboardComponent, directories *[]string) {
  1283. if component.Entity == entityType {
  1284. collectDirectoriesFromComponent(component, directories)
  1285. }
  1286. if len(component.Contents) > 0 {
  1287. searchSubcomponentsForDirectories(entityType, component.Contents, directories)
  1288. }
  1289. }
  1290. func collectDirectoriesFromComponent(component *config.DashboardComponent, directories *[]string) {
  1291. for _, subitem := range component.Contents {
  1292. if subitem.Type == "directory" {
  1293. *directories = append(*directories, subitem.Title)
  1294. }
  1295. }
  1296. }
  1297. func searchSubcomponentsForDirectories(entityType string, contents []*config.DashboardComponent, directories *[]string) {
  1298. for _, subitem := range contents {
  1299. findDirectoriesInEntityFieldsetsRecursive(entityType, subitem, directories)
  1300. }
  1301. }
  1302. func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) {
  1303. user := auth.UserFromApiCall(ctx, req, api.cfg)
  1304. if err := api.checkDashboardAccess(user); err != nil {
  1305. return nil, err
  1306. }
  1307. instances := entities.GetEntityInstances(req.Msg.Type)
  1308. if len(instances) == 0 {
  1309. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type))
  1310. }
  1311. entity, ok := instances[req.Msg.UniqueKey]
  1312. if !ok {
  1313. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
  1314. }
  1315. res := buildEntityResponse(entity, req.Msg.Type, api.cfg)
  1316. res.RelatedActions = api.relatedActionsForEntity(user, req.Msg.Type, entity)
  1317. return connect.NewResponse(res), nil
  1318. }
  1319. func entityTypeIcon(cfg *config.Config, entityType string) string {
  1320. entityFile := entityFileForType(cfg, entityType)
  1321. if entityFile == nil {
  1322. return ""
  1323. }
  1324. return entityFile.Icon
  1325. }
  1326. func entityFileForType(cfg *config.Config, entityType string) *config.EntityFile {
  1327. for _, entityFile := range cfg.Entities {
  1328. if entityFile != nil && entityFile.Name == entityType {
  1329. return entityFile
  1330. }
  1331. }
  1332. return nil
  1333. }
  1334. func entityPropertiesFromFile(entityFile *config.EntityFile) []config.EntityProperty {
  1335. if entityFile == nil {
  1336. return nil
  1337. }
  1338. return entityFile.Properties
  1339. }
  1340. func entityDefinitionProperties(properties []config.EntityProperty) []*apiv1.EntityProperty {
  1341. if len(properties) == 0 {
  1342. return nil
  1343. }
  1344. result := make([]*apiv1.EntityProperty, 0, len(properties))
  1345. for _, property := range properties {
  1346. result = append(result, &apiv1.EntityProperty{
  1347. Name: property.Name,
  1348. Title: property.Title,
  1349. })
  1350. }
  1351. return result
  1352. }
  1353. func entityListFields(data any, properties []config.EntityProperty) map[string]string {
  1354. if len(properties) == 0 {
  1355. return nil
  1356. }
  1357. fields := make(map[string]string, len(properties))
  1358. for _, property := range properties {
  1359. fields[property.Name] = entityPropertyValue(data, property.Name)
  1360. }
  1361. return fields
  1362. }
  1363. func entityPropertyValue(data any, propertyName string) string {
  1364. dataMap, ok := data.(map[string]any)
  1365. if !ok {
  1366. return ""
  1367. }
  1368. if value, found := dataMap[propertyName]; found {
  1369. return fmt.Sprintf("%v", value)
  1370. }
  1371. return entityPropertyValueCaseInsensitive(dataMap, propertyName)
  1372. }
  1373. func entityPropertyValueCaseInsensitive(dataMap map[string]any, propertyName string) string {
  1374. propertyNameLower := strings.ToLower(propertyName)
  1375. for key, value := range dataMap {
  1376. if strings.ToLower(key) == propertyNameLower {
  1377. return fmt.Sprintf("%v", value)
  1378. }
  1379. }
  1380. return ""
  1381. }
  1382. func buildEntityResponse(entity *entities.Entity, entityType string, cfg *config.Config) *apiv1.Entity {
  1383. properties := entityPropertiesFromFile(entityFileForType(cfg, entityType))
  1384. res := &apiv1.Entity{
  1385. Title: entity.Title,
  1386. UniqueKey: entity.UniqueKey,
  1387. Type: entityType,
  1388. Directories: findDirectoriesInEntityFieldsets(entityType, cfg.Dashboards),
  1389. Fields: entityFieldsForResponse(entity.Data, properties),
  1390. Icon: entityTypeIcon(cfg, entityType),
  1391. }
  1392. return res
  1393. }
  1394. func serializeEntityFields(data any) map[string]string {
  1395. if data == nil {
  1396. return nil
  1397. }
  1398. dataMap, ok := data.(map[string]any)
  1399. if !ok {
  1400. return nil
  1401. }
  1402. fields := make(map[string]string)
  1403. for k, v := range dataMap {
  1404. fields[k] = fmt.Sprintf("%v", v)
  1405. }
  1406. return fields
  1407. }
  1408. func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
  1409. execReqLogEntry, err := api.restartActionLogEntry(req.Msg.ExecutionTrackingId)
  1410. if err != nil {
  1411. return nil, err
  1412. }
  1413. if err := validateRestartLogEntry(execReqLogEntry); err != nil {
  1414. return nil, err
  1415. }
  1416. authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
  1417. execReq := executor.ExecutionRequest{
  1418. Binding: execReqLogEntry.Binding,
  1419. Arguments: copyStringMap(execReqLogEntry.Arguments),
  1420. Justification: execReqLogEntry.Justification,
  1421. AuthenticatedUser: authenticatedUser,
  1422. Cfg: api.cfg,
  1423. }
  1424. api.executor.ExecRequest(&execReq)
  1425. return connect.NewResponse(&apiv1.StartActionResponse{
  1426. ExecutionTrackingId: execReq.TrackingID,
  1427. }), nil
  1428. }
  1429. func (api *oliveTinAPI) restartActionLogEntry(executionTrackingId string) (*executor.InternalLogEntry, error) {
  1430. execReqLogEntry, found := api.executor.GetLog(executionTrackingId)
  1431. if !found {
  1432. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s", executionTrackingId))
  1433. }
  1434. if execReqLogEntry.Binding == nil {
  1435. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("log entry has no binding for tracking ID %s", executionTrackingId))
  1436. }
  1437. if execReqLogEntry.Binding.Action == nil {
  1438. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action not found for tracking ID %s", executionTrackingId))
  1439. }
  1440. return execReqLogEntry, nil
  1441. }
  1442. var (
  1443. executorListenersMu sync.Mutex
  1444. executorListeners = map[*executor.Executor]*oliveTinAPI{}
  1445. )
  1446. // RegisterExecutorListener registers the API server as an executor listener during startup.
  1447. // Call this before background goroutines that may trigger RebuildActionMap.
  1448. func RegisterExecutorListener(ex *executor.Executor) {
  1449. ensureExecutorListener(ex)
  1450. }
  1451. func ensureExecutorListener(ex *executor.Executor) *oliveTinAPI {
  1452. executorListenersMu.Lock()
  1453. defer executorListenersMu.Unlock()
  1454. if server, ok := executorListeners[ex]; ok {
  1455. return server
  1456. }
  1457. server := newServer(ex)
  1458. executorListeners[ex] = server
  1459. return server
  1460. }
  1461. func newServer(ex *executor.Executor) *oliveTinAPI {
  1462. server := &oliveTinAPI{
  1463. cfg: ex.Cfg,
  1464. executor: ex,
  1465. streamingClients: make(map[*streamingClient]struct{}),
  1466. }
  1467. ex.AddListener(server)
  1468. return server
  1469. }
  1470. func GetNewHandler(ex *executor.Executor) (string, http.Handler) {
  1471. server := ensureExecutorListener(ex)
  1472. jsonOpt := connectproto.WithJSON(
  1473. protojson.MarshalOptions{
  1474. EmitUnpopulated: true, // https://github.com/OliveTin/OliveTin/issues/674
  1475. },
  1476. protojson.UnmarshalOptions{
  1477. DiscardUnknown: true,
  1478. },
  1479. )
  1480. return apiv1connect.NewOliveTinApiServiceHandler(server, jsonOpt)
  1481. }