api.go 37 KB

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