api.go 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285
  1. package api
  2. import (
  3. ctx "context"
  4. "encoding/json"
  5. "os"
  6. "path"
  7. "sort"
  8. "connectrpc.com/connect"
  9. "google.golang.org/protobuf/encoding/protojson"
  10. apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
  11. apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect"
  12. "github.com/google/uuid"
  13. log "github.com/sirupsen/logrus"
  14. "fmt"
  15. "net/http"
  16. "sync"
  17. "time"
  18. acl "github.com/OliveTin/OliveTin/internal/acl"
  19. auth "github.com/OliveTin/OliveTin/internal/auth"
  20. authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
  21. config "github.com/OliveTin/OliveTin/internal/config"
  22. entities "github.com/OliveTin/OliveTin/internal/entities"
  23. executor "github.com/OliveTin/OliveTin/internal/executor"
  24. installationinfo "github.com/OliveTin/OliveTin/internal/installationinfo"
  25. "github.com/OliveTin/OliveTin/internal/tpl"
  26. connectproto "go.akshayshah.org/connectproto"
  27. )
  28. type oliveTinAPI struct {
  29. executor *executor.Executor
  30. cfg *config.Config
  31. // streamingClients is a set of currently connected clients.
  32. // The empty struct value models set semantics (keys only) and keeps add/remove O(1).
  33. // We use a map for efficient membership and deletion; ordering is not required.
  34. streamingClients map[*streamingClient]struct{}
  35. streamingClientsMutex sync.RWMutex
  36. }
  37. // This is used to avoid race conditions when iterating over the connectedClients map.
  38. // and holds the lock for as minimal time as possible to avoid blocking the API for too long.
  39. func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
  40. api.streamingClientsMutex.RLock()
  41. defer api.streamingClientsMutex.RUnlock()
  42. clients := make([]*streamingClient, 0, len(api.streamingClients))
  43. for client := range api.streamingClients {
  44. clients = append(clients, client)
  45. }
  46. return clients
  47. }
  48. type streamingClient struct {
  49. channel chan *apiv1.EventStreamResponse
  50. AuthenticatedUser *authpublic.AuthenticatedUser
  51. }
  52. func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.KillActionRequest]) (*connect.Response[apiv1.KillActionResponse], error) {
  53. ret := &apiv1.KillActionResponse{
  54. ExecutionTrackingId: req.Msg.ExecutionTrackingId,
  55. }
  56. var execReqLogEntry *executor.InternalLogEntry
  57. execReqLogEntry, ret.Found = api.executor.GetLog(req.Msg.ExecutionTrackingId)
  58. if !ret.Found {
  59. log.Warnf("Killing execution request not possible - not found by tracking ID: %v", req.Msg.ExecutionTrackingId)
  60. return connect.NewResponse(ret), nil
  61. }
  62. log.Warnf("Killing execution request by tracking ID: %v", req.Msg.ExecutionTrackingId)
  63. action := execReqLogEntry.Binding.Action
  64. if action == nil {
  65. log.Warnf("Killing execution request not possible - action not found: %v", execReqLogEntry.ActionTitle)
  66. ret.Killed = false
  67. return connect.NewResponse(ret), nil
  68. }
  69. user := auth.UserFromApiCall(ctx, req, api.cfg)
  70. api.killActionByTrackingId(user, action, execReqLogEntry, ret)
  71. return connect.NewResponse(ret), nil
  72. }
  73. func (api *oliveTinAPI) killActionByTrackingId(user *authpublic.AuthenticatedUser, action *config.Action, execReqLogEntry *executor.InternalLogEntry, ret *apiv1.KillActionResponse) {
  74. if !acl.IsAllowedKill(api.cfg, user, action) {
  75. log.Warnf("Killing execution request not possible - user not allowed to kill this action: %v", execReqLogEntry.ExecutionTrackingID)
  76. ret.Killed = false
  77. return
  78. }
  79. err := api.executor.Kill(execReqLogEntry)
  80. if err != nil {
  81. log.Warnf("Killing execution request err: %v", err)
  82. ret.AlreadyCompleted = true
  83. ret.Killed = false
  84. } else {
  85. ret.Killed = true
  86. }
  87. }
  88. func (api *oliveTinAPI) StartAction(ctx ctx.Context, req *connect.Request[apiv1.StartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
  89. args := make(map[string]string)
  90. for _, arg := range req.Msg.Arguments {
  91. args[arg.Name] = arg.Value
  92. }
  93. pair := api.executor.FindBindingByID(req.Msg.BindingId)
  94. if pair == nil || pair.Action == nil {
  95. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId))
  96. }
  97. authenticatedUser := auth.UserFromApiCall(ctx, req, api.cfg)
  98. execReq := executor.ExecutionRequest{
  99. Binding: pair,
  100. TrackingID: req.Msg.UniqueTrackingId,
  101. Arguments: args,
  102. AuthenticatedUser: authenticatedUser,
  103. Cfg: api.cfg,
  104. }
  105. api.executor.ExecRequest(&execReq)
  106. ret := &apiv1.StartActionResponse{
  107. ExecutionTrackingId: execReq.TrackingID,
  108. }
  109. return connect.NewResponse(ret), nil
  110. }
  111. func (api *oliveTinAPI) PasswordHash(ctx ctx.Context, req *connect.Request[apiv1.PasswordHashRequest]) (*connect.Response[apiv1.PasswordHashResponse], error) {
  112. hash, err := createHash(req.Msg.Password)
  113. if err != nil {
  114. return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("error creating hash: %w", err))
  115. }
  116. ret := &apiv1.PasswordHashResponse{
  117. Hash: hash,
  118. }
  119. return connect.NewResponse(ret), nil
  120. }
  121. func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) {
  122. // Check if local user authentication is enabled
  123. if !api.cfg.AuthLocalUsers.Enabled {
  124. return connect.NewResponse(&apiv1.LocalUserLoginResponse{
  125. Success: false,
  126. }), nil
  127. }
  128. match := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password)
  129. response := connect.NewResponse(&apiv1.LocalUserLoginResponse{
  130. Success: match,
  131. })
  132. if match {
  133. // Set authentication cookie for successful login
  134. user := api.cfg.FindUserByUsername(req.Msg.Username)
  135. if user != nil {
  136. sid := uuid.NewString()
  137. // Register the session in the session storage
  138. auth.RegisterUserSession(api.cfg, "local", sid, user.Username)
  139. log.WithFields(log.Fields{
  140. "username": user.Username,
  141. }).Info("LocalUserLogin: Session created and registered")
  142. // Set the authentication cookie in the response headers
  143. cookie := &http.Cookie{
  144. Name: "olivetin-sid-local",
  145. Value: sid,
  146. MaxAge: 31556952, // 1 year
  147. HttpOnly: true,
  148. Path: "/",
  149. }
  150. response.Header().Set("Set-Cookie", cookie.String())
  151. }
  152. log.WithFields(log.Fields{
  153. "username": req.Msg.Username,
  154. }).Info("LocalUserLogin: User logged in successfully.")
  155. } else {
  156. log.WithFields(log.Fields{
  157. "username": req.Msg.Username,
  158. }).Warn("LocalUserLogin: User login failed.")
  159. }
  160. return response, nil
  161. }
  162. func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionAndWaitRequest]) (*connect.Response[apiv1.StartActionAndWaitResponse], error) {
  163. args := make(map[string]string)
  164. for _, arg := range req.Msg.Arguments {
  165. args[arg.Name] = arg.Value
  166. }
  167. user := auth.UserFromApiCall(ctx, req, api.cfg)
  168. execReq := executor.ExecutionRequest{
  169. Binding: api.executor.FindBindingByID(req.Msg.ActionId),
  170. TrackingID: uuid.NewString(),
  171. Arguments: args,
  172. AuthenticatedUser: user,
  173. Cfg: api.cfg,
  174. }
  175. wg, _ := api.executor.ExecRequest(&execReq)
  176. wg.Wait()
  177. internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID)
  178. if ok {
  179. return connect.NewResponse(&apiv1.StartActionAndWaitResponse{
  180. LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
  181. }), nil
  182. } else {
  183. return nil, fmt.Errorf("execution not found")
  184. }
  185. }
  186. func (api *oliveTinAPI) StartActionByGet(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetRequest]) (*connect.Response[apiv1.StartActionByGetResponse], error) {
  187. args := make(map[string]string)
  188. execReq := executor.ExecutionRequest{
  189. Binding: api.executor.FindBindingByID(req.Msg.ActionId),
  190. TrackingID: uuid.NewString(),
  191. Arguments: args,
  192. AuthenticatedUser: auth.UserFromApiCall(ctx, req, api.cfg),
  193. Cfg: api.cfg,
  194. }
  195. _, uniqueTrackingId := api.executor.ExecRequest(&execReq)
  196. return connect.NewResponse(&apiv1.StartActionByGetResponse{
  197. ExecutionTrackingId: uniqueTrackingId,
  198. }), nil
  199. }
  200. func (api *oliveTinAPI) StartActionByGetAndWait(ctx ctx.Context, req *connect.Request[apiv1.StartActionByGetAndWaitRequest]) (*connect.Response[apiv1.StartActionByGetAndWaitResponse], error) {
  201. args := make(map[string]string)
  202. user := auth.UserFromApiCall(ctx, req, api.cfg)
  203. execReq := executor.ExecutionRequest{
  204. Binding: api.executor.FindBindingByID(req.Msg.ActionId),
  205. TrackingID: uuid.NewString(),
  206. Arguments: args,
  207. AuthenticatedUser: user,
  208. Cfg: api.cfg,
  209. }
  210. wg, _ := api.executor.ExecRequest(&execReq)
  211. wg.Wait()
  212. internalLogEntry, ok := api.executor.GetLog(execReq.TrackingID)
  213. if ok {
  214. return connect.NewResponse(&apiv1.StartActionByGetAndWaitResponse{
  215. LogEntry: api.internalLogEntryToPb(internalLogEntry, user),
  216. }), nil
  217. } else {
  218. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found"))
  219. }
  220. }
  221. func calculateRateLimitExpires(api *oliveTinAPI, logEntry *executor.InternalLogEntry) string {
  222. if logEntry.Binding == nil || logEntry.Binding.Action == nil {
  223. return ""
  224. }
  225. expiryUnix := api.executor.GetTimeUntilAvailable(logEntry.Binding)
  226. if expiryUnix <= 0 {
  227. return ""
  228. }
  229. return time.Unix(expiryUnix, 0).Format("2006-01-02 15:04:05")
  230. }
  231. func (api *oliveTinAPI) internalLogEntryToPb(logEntry *executor.InternalLogEntry, authenticatedUser *authpublic.AuthenticatedUser) *apiv1.LogEntry {
  232. pble := &apiv1.LogEntry{
  233. ActionTitle: logEntry.ActionTitle,
  234. ActionIcon: logEntry.ActionIcon,
  235. DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
  236. DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
  237. DatetimeIndex: logEntry.Index,
  238. Output: logEntry.Output,
  239. TimedOut: logEntry.TimedOut,
  240. Blocked: logEntry.Blocked,
  241. ExitCode: logEntry.ExitCode,
  242. Tags: logEntry.Tags,
  243. ExecutionTrackingId: logEntry.ExecutionTrackingID,
  244. ExecutionStarted: logEntry.ExecutionStarted,
  245. ExecutionFinished: logEntry.ExecutionFinished,
  246. User: logEntry.Username,
  247. BindingId: logEntry.GetBindingId(),
  248. DatetimeRateLimitExpires: calculateRateLimitExpires(api, logEntry),
  249. }
  250. if !pble.ExecutionFinished && logEntry.Binding != nil && logEntry.Binding.Action != nil {
  251. pble.CanKill = acl.IsAllowedKill(api.cfg, authenticatedUser, logEntry.Binding.Action)
  252. }
  253. return pble
  254. }
  255. func getExecutionStatusByTrackingID(api *oliveTinAPI, executionTrackingId string) *executor.InternalLogEntry {
  256. logEntry, ok := api.executor.GetLog(executionTrackingId)
  257. if !ok {
  258. return nil
  259. }
  260. return logEntry
  261. }
  262. // This is the actual action ID, not the binding ID.
  263. func getMostRecentExecutionStatusByActionId(api *oliveTinAPI, actionId string) *executor.InternalLogEntry {
  264. var ile *executor.InternalLogEntry
  265. binding := api.executor.FindBindingByID(actionId)
  266. if binding == nil {
  267. return nil
  268. }
  269. logs := api.executor.GetLogsByBindingId(binding.ID)
  270. if len(logs) == 0 {
  271. return nil
  272. }
  273. if len(logs) == 0 {
  274. return nil
  275. } else {
  276. // Get last log entry
  277. ile = logs[len(logs)-1]
  278. }
  279. return ile
  280. }
  281. func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[apiv1.ExecutionStatusRequest]) (*connect.Response[apiv1.ExecutionStatusResponse], error) {
  282. res := &apiv1.ExecutionStatusResponse{}
  283. user := auth.UserFromApiCall(ctx, req, api.cfg)
  284. if err := api.checkDashboardAccess(user); err != nil {
  285. return nil, err
  286. }
  287. var ile *executor.InternalLogEntry
  288. if req.Msg.ExecutionTrackingId != "" {
  289. ile = getExecutionStatusByTrackingID(api, req.Msg.ExecutionTrackingId)
  290. } else {
  291. ile = getMostRecentExecutionStatusByActionId(api, req.Msg.ActionId)
  292. }
  293. if ile == nil {
  294. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("execution not found for tracking ID %s or action ID %s", req.Msg.ExecutionTrackingId, req.Msg.ActionId))
  295. } else {
  296. res.LogEntry = api.internalLogEntryToPb(ile, user)
  297. }
  298. return connect.NewResponse(res), nil
  299. }
  300. func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.LogoutRequest]) (*connect.Response[apiv1.LogoutResponse], error) {
  301. user := auth.UserFromApiCall(ctx, req, api.cfg)
  302. log.WithFields(log.Fields{
  303. "username": user.Username,
  304. "provider": user.Provider,
  305. }).Info("Logout: User logged out")
  306. response := connect.NewResponse(&apiv1.LogoutResponse{})
  307. // Clear the local authentication cookie by setting it to expire
  308. localCookie := &http.Cookie{
  309. Name: "olivetin-sid-local",
  310. Value: "",
  311. MaxAge: -1, // This tells the browser to delete the cookie
  312. HttpOnly: true,
  313. Path: "/",
  314. }
  315. response.Header().Set("Set-Cookie", localCookie.String())
  316. // Clear the OAuth2 authentication cookie by setting it to expire
  317. oauth2Cookie := &http.Cookie{
  318. Name: "olivetin-sid-oauth",
  319. Value: "",
  320. MaxAge: -1, // This tells the browser to delete the cookie
  321. HttpOnly: true,
  322. Path: "/",
  323. }
  324. response.Header().Add("Set-Cookie", oauth2Cookie.String())
  325. return response, nil
  326. }
  327. func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[apiv1.GetActionBindingRequest]) (*connect.Response[apiv1.GetActionBindingResponse], error) {
  328. user := auth.UserFromApiCall(ctx, req, api.cfg)
  329. if err := api.checkDashboardAccess(user); err != nil {
  330. return nil, err
  331. }
  332. binding := api.executor.FindBindingByID(req.Msg.BindingId)
  333. if binding == nil {
  334. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action with ID %s not found", req.Msg.BindingId))
  335. }
  336. return connect.NewResponse(&apiv1.GetActionBindingResponse{
  337. Action: buildAction(binding, &DashboardRenderRequest{
  338. cfg: api.cfg,
  339. AuthenticatedUser: user,
  340. ex: api.executor,
  341. }),
  342. }), nil
  343. }
  344. func (api *oliveTinAPI) GetDashboard(ctx ctx.Context, req *connect.Request[apiv1.GetDashboardRequest]) (*connect.Response[apiv1.GetDashboardResponse], error) {
  345. user := auth.UserFromApiCall(ctx, req, api.cfg)
  346. if err := api.checkDashboardAccess(user); err != nil {
  347. return nil, err
  348. }
  349. entityType := ""
  350. entityKey := ""
  351. if req.Msg != nil {
  352. entityType = req.Msg.EntityType
  353. entityKey = req.Msg.EntityKey
  354. }
  355. dashboardRenderRequest := api.createDashboardRenderRequest(user, entityType, entityKey)
  356. if api.isDefaultDashboard(req.Msg.Title) {
  357. return api.buildDefaultDashboardResponse(dashboardRenderRequest)
  358. }
  359. return api.buildCustomDashboardResponse(dashboardRenderRequest, req.Msg.Title)
  360. }
  361. func (api *oliveTinAPI) checkDashboardAccess(user *authpublic.AuthenticatedUser) error {
  362. if user.IsGuest() && api.cfg.AuthRequireGuestsToLogin {
  363. return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("guests are not allowed to access the dashboard"))
  364. }
  365. return nil
  366. }
  367. func (api *oliveTinAPI) createDashboardRenderRequest(user *authpublic.AuthenticatedUser, entityType, entityKey string) *DashboardRenderRequest {
  368. return &DashboardRenderRequest{
  369. AuthenticatedUser: user,
  370. cfg: api.cfg,
  371. ex: api.executor,
  372. EntityType: entityType,
  373. EntityKey: entityKey,
  374. }
  375. }
  376. func (api *oliveTinAPI) isDefaultDashboard(title string) bool {
  377. return title == "default" || title == "" || title == "Actions"
  378. }
  379. func (api *oliveTinAPI) buildDefaultDashboardResponse(rr *DashboardRenderRequest) (*connect.Response[apiv1.GetDashboardResponse], error) {
  380. db := buildDefaultDashboard(rr)
  381. res := &apiv1.GetDashboardResponse{
  382. Dashboard: db,
  383. }
  384. return connect.NewResponse(res), nil
  385. }
  386. func (api *oliveTinAPI) buildCustomDashboardResponse(rr *DashboardRenderRequest, title string) (*connect.Response[apiv1.GetDashboardResponse], error) {
  387. res := &apiv1.GetDashboardResponse{
  388. Dashboard: renderDashboard(rr, title),
  389. }
  390. return connect.NewResponse(res), nil
  391. }
  392. func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetLogsRequest]) (*connect.Response[apiv1.GetLogsResponse], error) {
  393. user := auth.UserFromApiCall(ctx, req, api.cfg)
  394. if err := api.checkDashboardAccess(user); err != nil {
  395. return nil, err
  396. }
  397. ret := &apiv1.GetLogsResponse{}
  398. dateFilter := ""
  399. if req.Msg.DateFilter != "" {
  400. dateFilter = req.Msg.DateFilter
  401. }
  402. logEntries, paging := api.executor.GetLogTrackingIdsACL(api.cfg, user, req.Msg.StartOffset, api.cfg.LogHistoryPageSize, dateFilter)
  403. for _, le := range logEntries {
  404. ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
  405. }
  406. ret.CountRemaining = paging.CountRemaining
  407. ret.PageSize = paging.PageSize
  408. ret.TotalCount = paging.TotalCount
  409. ret.StartOffset = paging.StartOffset
  410. return connect.NewResponse(ret), nil
  411. }
  412. // isValidLogEntry checks if a log entry has all required fields populated.
  413. func isValidLogEntry(e *executor.InternalLogEntry) bool {
  414. return e != nil && e.Binding != nil && e.Binding.Action != nil
  415. }
  416. // isLogEntryAllowed checks if a log entry is allowed to be viewed by the user.
  417. func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
  418. return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
  419. }
  420. // buildEmptyPageResponse creates a response for an empty page.
  421. func buildEmptyPageResponse(page pageInfo) *apiv1.GetActionLogsResponse {
  422. return &apiv1.GetActionLogsResponse{
  423. CountRemaining: 0,
  424. PageSize: page.size,
  425. TotalCount: page.total,
  426. StartOffset: page.start,
  427. }
  428. }
  429. // calculateReversedIndices computes the reversed indices for newest-first pagination.
  430. func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) {
  431. startIdx := page.total - page.end
  432. endIdx := page.total - page.start
  433. if startIdx < 0 {
  434. startIdx = 0
  435. }
  436. if endIdx > int64(filteredLen) {
  437. endIdx = int64(filteredLen)
  438. }
  439. return startIdx, endIdx
  440. }
  441. // buildActionLogsResponse builds the response with paginated log entries.
  442. func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *authpublic.AuthenticatedUser) *apiv1.GetActionLogsResponse {
  443. startIdx, endIdx := calculateReversedIndices(page, len(filtered))
  444. ret := &apiv1.GetActionLogsResponse{}
  445. for _, le := range filtered[startIdx:endIdx] {
  446. ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
  447. }
  448. ret.CountRemaining = page.start
  449. ret.PageSize = page.size
  450. ret.TotalCount = page.total
  451. ret.StartOffset = page.start
  452. return ret
  453. }
  454. func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv1.GetActionLogsRequest]) (*connect.Response[apiv1.GetActionLogsResponse], error) {
  455. user := auth.UserFromApiCall(ctx, req, api.cfg)
  456. if err := api.checkDashboardAccess(user); err != nil {
  457. return nil, err
  458. }
  459. filtered := api.filterLogsByACL(api.executor.GetLogsByBindingId(req.Msg.ActionId), user)
  460. page := paginate(int64(len(filtered)), api.cfg.LogHistoryPageSize, req.Msg.StartOffset)
  461. if page.empty {
  462. return connect.NewResponse(buildEmptyPageResponse(page)), nil
  463. }
  464. return connect.NewResponse(api.buildActionLogsResponse(filtered, page, user)), nil
  465. }
  466. func (api *oliveTinAPI) filterLogsByACL(entries []*executor.InternalLogEntry, user *authpublic.AuthenticatedUser) []*executor.InternalLogEntry {
  467. filtered := make([]*executor.InternalLogEntry, 0, len(entries))
  468. for _, e := range entries {
  469. if !isValidLogEntry(e) {
  470. continue
  471. }
  472. if api.isLogEntryAllowed(e, user) {
  473. filtered = append(filtered, e)
  474. }
  475. }
  476. return filtered
  477. }
  478. type pageInfo struct {
  479. total int64
  480. size int64
  481. start int64
  482. end int64
  483. empty bool
  484. }
  485. func paginate(total int64, size int64, start int64) pageInfo {
  486. if start < 0 {
  487. start = 0
  488. }
  489. if start >= total {
  490. return pageInfo{total: total, size: size, start: start, end: start, empty: true}
  491. }
  492. end := start + size
  493. if end > total {
  494. end = total
  495. }
  496. return pageInfo{total: total, size: size, start: start, end: end, empty: false}
  497. }
  498. /*
  499. This function is ONLY a helper for the UI - the arguments are validated properly
  500. on the StartAction -> Executor chain. This is here basically to provide helpful
  501. error messages more quickly before starting the action.
  502. It uses the same validation logic as the executor, including mangling argument
  503. values (e.g., datetime formatting, checkbox title-to-value conversion).
  504. */
  505. func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
  506. err := api.validateArgumentTypeInternal(req.Msg)
  507. desc := ""
  508. if err != nil {
  509. desc = err.Error()
  510. }
  511. return connect.NewResponse(&apiv1.ValidateArgumentTypeResponse{
  512. Valid: err == nil,
  513. Description: desc,
  514. }), nil
  515. }
  516. func (api *oliveTinAPI) validateArgumentTypeInternal(msg *apiv1.ValidateArgumentTypeRequest) error {
  517. if msg.BindingId == "" || msg.ArgumentName == "" {
  518. return executor.TypeSafetyCheck("", msg.Value, msg.Type)
  519. }
  520. arg, action := api.findArgumentForValidation(msg.BindingId, msg.ArgumentName)
  521. if arg == nil {
  522. return fmt.Errorf("argument not found")
  523. }
  524. return executor.ValidateArgument(arg, msg.Value, action)
  525. }
  526. func (api *oliveTinAPI) findArgumentForValidation(bindingId string, argumentName string) (*config.ActionArgument, *config.Action) {
  527. binding := api.executor.FindBindingByID(bindingId)
  528. if binding == nil || binding.Action == nil {
  529. return nil, nil
  530. }
  531. arg := api.findArgumentByName(binding.Action, argumentName)
  532. return arg, binding.Action
  533. }
  534. func (api *oliveTinAPI) findArgumentByName(action *config.Action, name string) *config.ActionArgument {
  535. for i := range action.Arguments {
  536. if action.Arguments[i].Name == name {
  537. return &action.Arguments[i]
  538. }
  539. }
  540. return nil
  541. }
  542. func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) {
  543. user := auth.UserFromApiCall(ctx, req, api.cfg)
  544. if err := api.checkDashboardAccess(user); err != nil {
  545. return nil, err
  546. }
  547. res := &apiv1.WhoAmIResponse{
  548. AuthenticatedUser: user.Username,
  549. Usergroup: user.UsergroupLine,
  550. Provider: user.Provider,
  551. Sid: user.SID,
  552. Acls: user.Acls,
  553. }
  554. return connect.NewResponse(res), nil
  555. }
  556. func (api *oliveTinAPI) SosReport(ctx ctx.Context, req *connect.Request[apiv1.SosReportRequest]) (*connect.Response[apiv1.SosReportResponse], error) {
  557. sos := installationinfo.GetSosReport()
  558. if !api.cfg.InsecureAllowDumpSos {
  559. log.Info(sos)
  560. sos = "Your SOS Report has been logged to OliveTin logs.\n\nIf you are in a safe network, you can temporarily set `insecureAllowDumpSos: true` in your config.yaml, restart OliveTin, and refresh this page - it will put the output directly in the browser."
  561. }
  562. ret := &apiv1.SosReportResponse{
  563. Alert: sos,
  564. }
  565. return connect.NewResponse(ret), nil
  566. }
  567. func (api *oliveTinAPI) DumpVars(ctx ctx.Context, req *connect.Request[apiv1.DumpVarsRequest]) (*connect.Response[apiv1.DumpVarsResponse], error) {
  568. res := &apiv1.DumpVarsResponse{}
  569. if !api.cfg.InsecureAllowDumpVars {
  570. res.Alert = "Dumping variables is not allowed by default because it is insecure."
  571. return connect.NewResponse(res), nil
  572. }
  573. jsonstring, err := json.MarshalIndent(tpl.GetNewGeneralTemplateContext(), "", " ")
  574. if err != nil {
  575. log.WithError(err).Error("DumpVars: failed to marshal template context from GetNewGeneralTemplateContext")
  576. return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("dump vars: marshal template context: %w", err))
  577. }
  578. fmt.Printf("%s", jsonstring)
  579. res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpVars = false again after you don't need it anymore"
  580. return connect.NewResponse(res), nil
  581. }
  582. func (api *oliveTinAPI) DumpPublicIdActionMap(ctx ctx.Context, req *connect.Request[apiv1.DumpPublicIdActionMapRequest]) (*connect.Response[apiv1.DumpPublicIdActionMapResponse], error) {
  583. res := &apiv1.DumpPublicIdActionMapResponse{}
  584. res.Contents = make(map[string]*apiv1.DebugBinding)
  585. if !api.cfg.InsecureAllowDumpActionMap {
  586. res.Alert = "Dumping Public IDs is disallowed."
  587. return connect.NewResponse(res), nil
  588. }
  589. api.executor.MapActionBindingsLock.RLock()
  590. for k, v := range api.executor.MapActionBindings {
  591. res.Contents[k] = &apiv1.DebugBinding{
  592. ActionTitle: v.Action.Title,
  593. }
  594. }
  595. api.executor.MapActionBindingsLock.RUnlock()
  596. res.Alert = "Dumping variables has been enabled in the configuration. Please set InsecureAllowDumpActionMap = false again after you don't need it anymore"
  597. return connect.NewResponse(res), nil
  598. }
  599. func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.GetReadyzRequest]) (*connect.Response[apiv1.GetReadyzResponse], error) {
  600. res := &apiv1.GetReadyzResponse{
  601. Status: "OK",
  602. }
  603. return connect.NewResponse(res), nil
  604. }
  605. func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error {
  606. log.Debugf("EventStream: %v", req.Msg)
  607. // Set X-Accel-Buffering header to disable nginx buffering for this stream
  608. // https://github.com/OliveTin/OliveTin/issues/765
  609. srv.ResponseHeader().Set("X-Accel-Buffering", "no")
  610. user := auth.UserFromApiCall(ctx, req, api.cfg)
  611. if err := api.checkDashboardAccess(user); err != nil {
  612. return err
  613. }
  614. client := &streamingClient{
  615. channel: make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
  616. AuthenticatedUser: user,
  617. }
  618. log.WithFields(log.Fields{
  619. "authenticatedUser": user.Username,
  620. }).Debugf("EventStream: client connected")
  621. api.streamingClientsMutex.Lock()
  622. api.streamingClients[client] = struct{}{}
  623. api.streamingClientsMutex.Unlock()
  624. // loop over client channel and send events to connectedClient
  625. for msg := range client.channel {
  626. log.Debugf("Sending event to client: %v", msg)
  627. if err := srv.Send(msg); err != nil {
  628. log.Errorf("Error sending event to client: %v", err)
  629. // Remove disconnected client from the list
  630. api.removeClient(client)
  631. break
  632. }
  633. }
  634. log.Infof("EventStream: client disconnected")
  635. return nil
  636. }
  637. func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) {
  638. api.streamingClientsMutex.Lock()
  639. delete(api.streamingClients, clientToRemove)
  640. api.streamingClientsMutex.Unlock()
  641. close(clientToRemove.channel)
  642. }
  643. func (api *oliveTinAPI) OnActionMapRebuilt() {
  644. toRemove := []*streamingClient{}
  645. for _, client := range api.copyOfStreamingClients() {
  646. select {
  647. case client.channel <- &apiv1.EventStreamResponse{
  648. Event: &apiv1.EventStreamResponse_ConfigChanged{
  649. ConfigChanged: &apiv1.EventConfigChanged{},
  650. },
  651. }:
  652. default:
  653. log.Warnf("EventStream: client channel is full, removing client")
  654. toRemove = append(toRemove, client)
  655. }
  656. }
  657. for _, client := range toRemove {
  658. api.removeClient(client)
  659. }
  660. }
  661. func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
  662. toRemove := []*streamingClient{}
  663. for _, client := range api.copyOfStreamingClients() {
  664. select {
  665. case client.channel <- &apiv1.EventStreamResponse{
  666. Event: &apiv1.EventStreamResponse_ExecutionStarted{
  667. ExecutionStarted: &apiv1.EventExecutionStarted{
  668. LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
  669. },
  670. },
  671. }:
  672. default:
  673. log.Warnf("EventStream: client channel is full, removing client")
  674. toRemove = append(toRemove, client)
  675. }
  676. }
  677. for _, client := range toRemove {
  678. api.removeClient(client)
  679. }
  680. }
  681. func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) {
  682. toRemove := []*streamingClient{}
  683. for _, client := range api.copyOfStreamingClients() {
  684. select {
  685. case client.channel <- &apiv1.EventStreamResponse{
  686. Event: &apiv1.EventStreamResponse_ExecutionFinished{
  687. ExecutionFinished: &apiv1.EventExecutionFinished{
  688. LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser),
  689. },
  690. },
  691. }:
  692. default:
  693. log.Warnf("EventStream: client channel is full, removing client")
  694. toRemove = append(toRemove, client)
  695. }
  696. }
  697. for _, client := range toRemove {
  698. api.removeClient(client)
  699. }
  700. }
  701. func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) {
  702. res := &apiv1.GetDiagnosticsResponse{
  703. SshFoundKey: installationinfo.Runtime.SshFoundKey,
  704. SshFoundConfig: installationinfo.Runtime.SshFoundConfig,
  705. }
  706. return connect.NewResponse(res), nil
  707. }
  708. func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
  709. user := auth.UserFromApiCall(ctx, req, api.cfg)
  710. loginRequired := user.IsGuest() && api.cfg.AuthRequireGuestsToLogin
  711. res := &apiv1.InitResponse{
  712. ShowFooter: api.cfg.ShowFooter,
  713. ShowNavigation: api.cfg.ShowNavigation,
  714. ShowNewVersions: api.cfg.ShowNewVersions,
  715. AvailableVersion: installationinfo.Runtime.AvailableVersion,
  716. CurrentVersion: installationinfo.Build.Version,
  717. PageTitle: api.cfg.PageTitle,
  718. SectionNavigationStyle: api.cfg.SectionNavigationStyle,
  719. DefaultIconForBack: api.cfg.DefaultIconForBack,
  720. EnableCustomJs: api.cfg.EnableCustomJs,
  721. AuthLoginUrl: api.cfg.AuthLoginUrl,
  722. AuthLocalLogin: api.cfg.AuthLocalUsers.Enabled,
  723. OAuth2Providers: buildPublicOAuth2ProvidersList(api.cfg),
  724. AdditionalLinks: buildAdditionalLinks(api.cfg.AdditionalNavigationLinks),
  725. StyleMods: api.cfg.StyleMods,
  726. RootDashboards: api.buildRootDashboards(user, api.cfg.Dashboards),
  727. AuthenticatedUser: user.Username,
  728. AuthenticatedUserProvider: user.Provider,
  729. EffectivePolicy: buildEffectivePolicy(user.EffectivePolicy),
  730. BannerMessage: api.cfg.BannerMessage,
  731. BannerCss: api.cfg.BannerCSS,
  732. ShowDiagnostics: user.EffectivePolicy.ShowDiagnostics,
  733. ShowLogList: user.EffectivePolicy.ShowLogList,
  734. LoginRequired: loginRequired,
  735. AvailableThemes: discoverAvailableThemes(api.cfg),
  736. ShowNavigateOnStartIcons: api.cfg.ShowNavigateOnStartIcons,
  737. }
  738. return connect.NewResponse(res), nil
  739. }
  740. // discoverAvailableThemes finds all available themes in the custom-webui/themes directory.
  741. // A theme is considered available if it has a theme.css file.
  742. func discoverAvailableThemes(cfg *config.Config) []string {
  743. configDir := cfg.GetDir()
  744. if configDir == "" {
  745. return []string{}
  746. }
  747. themesDir := path.Join(configDir, "custom-webui", "themes")
  748. entries, err := os.ReadDir(themesDir)
  749. if err != nil {
  750. log.WithFields(log.Fields{
  751. "themesDir": themesDir,
  752. "error": err,
  753. }).Tracef("Could not read themes directory")
  754. return []string{}
  755. }
  756. themes := collectValidThemes(themesDir, entries)
  757. sort.Strings(themes)
  758. return themes
  759. }
  760. // collectValidThemes collects theme names from directory entries that have a theme.css file.
  761. func collectValidThemes(themesDir string, entries []os.DirEntry) []string {
  762. var themes []string
  763. for _, entry := range entries {
  764. if themeName := getValidThemeName(themesDir, entry); themeName != "" {
  765. themes = append(themes, themeName)
  766. }
  767. }
  768. return themes
  769. }
  770. // getValidThemeName returns the theme name if the entry is a valid theme directory with theme.css, otherwise returns empty string.
  771. func getValidThemeName(themesDir string, entry os.DirEntry) string {
  772. if !entry.IsDir() {
  773. return ""
  774. }
  775. themeName := entry.Name()
  776. themeCssPath := path.Join(themesDir, themeName, "theme.css")
  777. if _, err := os.Stat(themeCssPath); err != nil {
  778. return ""
  779. }
  780. return themeName
  781. }
  782. func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
  783. var rootDashboards []string
  784. dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
  785. api.addDefaultDashboardIfNeeded(&rootDashboards, dashboardRenderRequest)
  786. api.addCustomDashboards(&rootDashboards, dashboards, dashboardRenderRequest)
  787. return rootDashboards
  788. }
  789. func (api *oliveTinAPI) addDefaultDashboardIfNeeded(rootDashboards *[]string, rr *DashboardRenderRequest) {
  790. defaultDashboard := buildDefaultDashboard(rr)
  791. if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 {
  792. log.Tracef("defaultDashboard: %+v", defaultDashboard.Contents)
  793. *rootDashboards = append(*rootDashboards, "Actions")
  794. }
  795. }
  796. func (api *oliveTinAPI) addCustomDashboards(rootDashboards *[]string, dashboards []*config.DashboardComponent, rr *DashboardRenderRequest) {
  797. for _, dashboard := range dashboards {
  798. // We have to build the dashboard response instead of just looping over config.dashboards,
  799. // because we need to check if the user has access to the dashboard
  800. db := renderDashboard(rr, dashboard.Title)
  801. if db != nil {
  802. *rootDashboards = append(*rootDashboards, dashboard.Title)
  803. }
  804. }
  805. }
  806. func buildPublicOAuth2ProvidersList(cfg *config.Config) []*apiv1.OAuth2Provider {
  807. var publicProviders []*apiv1.OAuth2Provider
  808. for providerKey, provider := range cfg.AuthOAuth2Providers {
  809. publicProviders = append(publicProviders, &apiv1.OAuth2Provider{
  810. Title: provider.Title,
  811. Icon: provider.Icon,
  812. Key: providerKey,
  813. })
  814. }
  815. sort.Slice(publicProviders, func(i, j int) bool {
  816. return publicProviders[i].Key < publicProviders[j].Key
  817. })
  818. return publicProviders
  819. }
  820. func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLink {
  821. var additionalLinks []*apiv1.AdditionalLink
  822. for _, link := range links {
  823. additionalLinks = append(additionalLinks, &apiv1.AdditionalLink{
  824. Title: link.Title,
  825. Url: link.Url,
  826. })
  827. }
  828. return additionalLinks
  829. }
  830. func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
  831. toRemove := []*streamingClient{}
  832. for _, client := range api.copyOfStreamingClients() {
  833. select {
  834. case client.channel <- &apiv1.EventStreamResponse{
  835. Event: &apiv1.EventStreamResponse_OutputChunk{
  836. OutputChunk: &apiv1.EventOutputChunk{
  837. Output: string(content),
  838. ExecutionTrackingId: executionTrackingId,
  839. },
  840. },
  841. }:
  842. default:
  843. log.Warnf("EventStream: client channel is full, removing client")
  844. toRemove = append(toRemove, client)
  845. }
  846. }
  847. for _, client := range toRemove {
  848. api.removeClient(client)
  849. }
  850. }
  851. func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
  852. user := auth.UserFromApiCall(ctx, req, api.cfg)
  853. if err := api.checkDashboardAccess(user); err != nil {
  854. return nil, err
  855. }
  856. entityMap := entities.GetEntities()
  857. entityNames := make([]string, 0, len(entityMap))
  858. for name := range entityMap {
  859. entityNames = append(entityNames, name)
  860. }
  861. sort.Strings(entityNames)
  862. entityDefinitions := make([]*apiv1.EntityDefinition, 0, len(entityNames))
  863. for _, name := range entityNames {
  864. def := &apiv1.EntityDefinition{
  865. Title: name,
  866. UsedOnDashboards: findDashboardsForEntity(name, api.cfg.Dashboards),
  867. Instances: buildSortedEntityInstances(name, entityMap[name]),
  868. }
  869. entityDefinitions = append(entityDefinitions, def)
  870. }
  871. res := &apiv1.GetEntitiesResponse{
  872. EntityDefinitions: entityDefinitions,
  873. }
  874. return connect.NewResponse(res), nil
  875. }
  876. func buildSortedEntityInstances(entityType string, entityInstances map[string]*entities.Entity) []*apiv1.Entity {
  877. instanceKeys := make([]string, 0, len(entityInstances))
  878. for key := range entityInstances {
  879. instanceKeys = append(instanceKeys, key)
  880. }
  881. sort.Strings(instanceKeys)
  882. instances := make([]*apiv1.Entity, 0, len(instanceKeys))
  883. for _, key := range instanceKeys {
  884. e := entityInstances[key]
  885. instances = append(instances, &apiv1.Entity{
  886. Title: e.Title,
  887. UniqueKey: e.UniqueKey,
  888. Type: entityType,
  889. })
  890. }
  891. return instances
  892. }
  893. func findDashboardsForEntity(entityTitle string, dashboards []*config.DashboardComponent) []string {
  894. var foundDashboards []string
  895. seen := make(map[string]bool)
  896. findEntityInComponents(entityTitle, "", dashboards, &foundDashboards, seen)
  897. return foundDashboards
  898. }
  899. func findEntityInComponents(entityTitle string, parentTitle string, components []*config.DashboardComponent, foundDashboards *[]string, seen map[string]bool) {
  900. for _, component := range components {
  901. if component.Entity == entityTitle {
  902. addEntityDashboard(component, parentTitle, foundDashboards, seen)
  903. }
  904. if len(component.Contents) > 0 {
  905. findEntityInComponents(entityTitle, component.Title, component.Contents, foundDashboards, seen)
  906. }
  907. }
  908. }
  909. func addEntityDashboard(component *config.DashboardComponent, parentTitle string, foundDashboards *[]string, seen map[string]bool) {
  910. if component.Type == "directory" {
  911. addEntityDirectory(component, foundDashboards, seen)
  912. } else {
  913. addParentDashboard(parentTitle, foundDashboards, seen)
  914. }
  915. }
  916. func addEntityDirectory(component *config.DashboardComponent, foundDashboards *[]string, seen map[string]bool) {
  917. dashboardTitle := component.Title + " [Entity Directory]"
  918. if !seen[dashboardTitle] {
  919. *foundDashboards = append(*foundDashboards, dashboardTitle)
  920. seen[dashboardTitle] = true
  921. seen[component.Title] = true
  922. }
  923. }
  924. func addParentDashboard(parentTitle string, foundDashboards *[]string, seen map[string]bool) {
  925. if parentTitle != "" && !seen[parentTitle] {
  926. *foundDashboards = append(*foundDashboards, parentTitle)
  927. seen[parentTitle] = true
  928. }
  929. }
  930. func findDirectoriesInEntityFieldsets(entityType string, dashboards []*config.DashboardComponent) []string {
  931. var directories []string
  932. for _, dashboard := range dashboards {
  933. findDirectoriesInEntityFieldsetsRecursive(entityType, dashboard, &directories)
  934. }
  935. return directories
  936. }
  937. func findDirectoriesInEntityFieldsetsRecursive(entityType string, component *config.DashboardComponent, directories *[]string) {
  938. if component.Entity == entityType {
  939. collectDirectoriesFromComponent(component, directories)
  940. }
  941. if len(component.Contents) > 0 {
  942. searchSubcomponentsForDirectories(entityType, component.Contents, directories)
  943. }
  944. }
  945. func collectDirectoriesFromComponent(component *config.DashboardComponent, directories *[]string) {
  946. for _, subitem := range component.Contents {
  947. if subitem.Type == "directory" {
  948. *directories = append(*directories, subitem.Title)
  949. }
  950. }
  951. }
  952. func searchSubcomponentsForDirectories(entityType string, contents []*config.DashboardComponent, directories *[]string) {
  953. for _, subitem := range contents {
  954. findDirectoriesInEntityFieldsetsRecursive(entityType, subitem, directories)
  955. }
  956. }
  957. func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) {
  958. user := auth.UserFromApiCall(ctx, req, api.cfg)
  959. if err := api.checkDashboardAccess(user); err != nil {
  960. return nil, err
  961. }
  962. instances := entities.GetEntityInstances(req.Msg.Type)
  963. if len(instances) == 0 {
  964. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity type %s not found", req.Msg.Type))
  965. }
  966. entity, ok := instances[req.Msg.UniqueKey]
  967. if !ok {
  968. return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("entity with unique key %s not found in type %s", req.Msg.UniqueKey, req.Msg.Type))
  969. }
  970. res := buildEntityResponse(entity, req.Msg.Type, api.cfg.Dashboards)
  971. return connect.NewResponse(res), nil
  972. }
  973. func buildEntityResponse(entity *entities.Entity, entityType string, dashboards []*config.DashboardComponent) *apiv1.Entity {
  974. res := &apiv1.Entity{
  975. Title: entity.Title,
  976. UniqueKey: entity.UniqueKey,
  977. Type: entityType,
  978. Directories: findDirectoriesInEntityFieldsets(entityType, dashboards),
  979. Fields: serializeEntityFields(entity.Data),
  980. }
  981. return res
  982. }
  983. func serializeEntityFields(data any) map[string]string {
  984. if data == nil {
  985. return nil
  986. }
  987. dataMap, ok := data.(map[string]any)
  988. if !ok {
  989. return nil
  990. }
  991. fields := make(map[string]string)
  992. for k, v := range dataMap {
  993. fields[k] = fmt.Sprintf("%v", v)
  994. }
  995. return fields
  996. }
  997. func (api *oliveTinAPI) RestartAction(ctx ctx.Context, req *connect.Request[apiv1.RestartActionRequest]) (*connect.Response[apiv1.StartActionResponse], error) {
  998. ret := &apiv1.StartActionResponse{
  999. ExecutionTrackingId: req.Msg.ExecutionTrackingId,
  1000. }
  1001. var execReqLogEntry *executor.InternalLogEntry
  1002. execReqLogEntry, found := api.executor.GetLog(req.Msg.ExecutionTrackingId)
  1003. if !found {
  1004. log.Warnf("Restarting execution request not possible - not found by tracking ID: %v", req.Msg.ExecutionTrackingId)
  1005. return connect.NewResponse(ret), nil
  1006. }
  1007. log.Warnf("Restarting execution request by tracking ID: %v", req.Msg.ExecutionTrackingId)
  1008. action := execReqLogEntry.Binding.Action
  1009. if action == nil {
  1010. log.Warnf("Restarting execution request not possible - action not found: %v", execReqLogEntry.ActionTitle)
  1011. return connect.NewResponse(ret), nil
  1012. }
  1013. return api.StartAction(ctx, &connect.Request[apiv1.StartActionRequest]{
  1014. Msg: &apiv1.StartActionRequest{
  1015. // FIXME
  1016. UniqueTrackingId: req.Msg.ExecutionTrackingId,
  1017. },
  1018. })
  1019. }
  1020. func newServer(ex *executor.Executor) *oliveTinAPI {
  1021. server := oliveTinAPI{}
  1022. server.cfg = ex.Cfg
  1023. server.executor = ex
  1024. server.streamingClients = make(map[*streamingClient]struct{})
  1025. ex.AddListener(&server)
  1026. return &server
  1027. }
  1028. func GetNewHandler(ex *executor.Executor) (string, http.Handler) {
  1029. server := newServer(ex)
  1030. jsonOpt := connectproto.WithJSON(
  1031. protojson.MarshalOptions{
  1032. EmitUnpopulated: true, // https://github.com/OliveTin/OliveTin/issues/674
  1033. },
  1034. protojson.UnmarshalOptions{
  1035. DiscardUnknown: true,
  1036. },
  1037. )
  1038. return apiv1connect.NewOliveTinApiServiceHandler(server, jsonOpt)
  1039. }