• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gameap / gameap / 32674281665

23 Aug 2026 11:39PM UTC coverage: 85.345% (+0.08%) from 85.269%
32674281665

Pull #71

github

et-nik
update PLUGIN_MAX_MODULE_SIZE_MB
Pull Request #71: plugin system fixes and updates

1348 of 1590 new or added lines in 38 files covered. (84.78%)

14 existing lines in 6 files now uncovered.

57899 of 67841 relevant lines covered (85.35%)

29512.83 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.29
/internal/api/pluginstore/updateplugin/handler.go
1
package updateplugin
2

3
import (
4
        "context"
5
        "encoding/json"
6
        "log/slog"
7
        "net/http"
8
        "path"
9
        "slices"
10
        "time"
11

12
        "github.com/gameap/gameap/internal/api/base"
13
        "github.com/gameap/gameap/internal/domain"
14
        "github.com/gameap/gameap/internal/files"
15
        "github.com/gameap/gameap/internal/filters"
16
        "github.com/gameap/gameap/internal/plugin"
17
        "github.com/gameap/gameap/internal/repositories"
18
        "github.com/gameap/gameap/internal/services/plugininstall"
19
        "github.com/gameap/gameap/internal/services/pluginstore"
20
        "github.com/gameap/gameap/pkg/api"
21
        pkgplugin "github.com/gameap/gameap/pkg/plugin"
22
        "github.com/pkg/errors"
23
)
24

25
type Handler struct {
26
        storeService  *pluginstore.Service
27
        pluginRepo    repositories.PluginRepository
28
        fileManager   files.FileManager
29
        loader        *plugin.Loader
30
        subscriptions plugininstall.SubscriptionRefresher
31
        pluginsDir    string
32
        responder     base.Responder
33
}
34

35
func NewHandler(
36
        storeService *pluginstore.Service,
37
        pluginRepo repositories.PluginRepository,
38
        fileManager files.FileManager,
39
        loader *plugin.Loader,
40
        subscriptions plugininstall.SubscriptionRefresher,
41
        pluginsDir string,
42
        responder base.Responder,
43
) *Handler {
17✔
44
        return &Handler{
17✔
45
                storeService:  storeService,
17✔
46
                pluginRepo:    pluginRepo,
17✔
47
                fileManager:   fileManager,
17✔
48
                loader:        loader,
17✔
49
                subscriptions: subscriptions,
17✔
50
                pluginsDir:    pluginsDir,
17✔
51
                responder:     responder,
17✔
52
        }
17✔
53
}
17✔
54

55
const extendedWriteDeadline = 5 * time.Minute
56

57
type input struct {
58
        Version string `json:"version"`
59
}
60

61
func (h *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
17✔
62
        ctx := r.Context()
17✔
63

17✔
64
        rc := http.NewResponseController(rw)
17✔
65
        if err := rc.SetWriteDeadline(time.Now().Add(extendedWriteDeadline)); err != nil {
34✔
66
                slog.WarnContext(ctx, "failed to extend write deadline", slog.String("error", err.Error()))
17✔
67
        }
17✔
68

69
        storePluginID, err := api.NewInputReader(r).ReadString("id")
17✔
70
        if err != nil {
17✔
71
                h.responder.WriteError(ctx, rw, errors.WithMessage(err, "failed to read plugin ID"))
×
72

×
73
                return
×
74
        }
×
75

76
        inp, err := h.parseInput(r)
17✔
77
        if err != nil {
18✔
78
                h.responder.WriteError(ctx, rw, err)
1✔
79

1✔
80
                return
1✔
81
        }
1✔
82

83
        dbID := pkgplugin.ParsePluginID(storePluginID)
16✔
84

16✔
85
        pluginRecord, err := h.findInstalledPlugin(ctx, dbID)
16✔
86
        if err != nil {
18✔
87
                h.responder.WriteError(ctx, rw, err)
2✔
88

2✔
89
                return
2✔
90
        }
2✔
91

92
        if err := h.unloadPlugin(ctx, dbID); err != nil {
15✔
93
                h.responder.WriteError(ctx, rw, err)
1✔
94

1✔
95
                return
1✔
96
        }
1✔
97
        defer plugininstall.RefreshSubscriptions(ctx, h.subscriptions)
13✔
98

13✔
99
        selectedVersion, err := h.selectVersion(ctx, storePluginID, inp.Version)
13✔
100
        if err != nil {
16✔
101
                h.responder.WriteError(ctx, rw, err)
3✔
102

3✔
103
                return
3✔
104
        }
3✔
105

106
        wasmBytes, err := h.downloadAndVerify(ctx, storePluginID, selectedVersion)
10✔
107
        if err != nil {
12✔
108
                h.responder.WriteError(ctx, rw, err)
2✔
109

2✔
110
                return
2✔
111
        }
2✔
112

113
        filename := storePluginID + ".wasm"
8✔
114
        pluginPath := path.Join(h.pluginsDir, filename)
8✔
115

8✔
116
        if err := h.fileManager.Write(ctx, pluginPath, wasmBytes); err != nil {
9✔
117
                h.responder.WriteError(ctx, rw, errors.WithMessage(err, "failed to save plugin file"))
1✔
118

1✔
119
                return
1✔
120
        }
1✔
121

122
        h.updatePluginRecord(pluginRecord, selectedVersion, filename)
7✔
123

7✔
124
        if err := h.pluginRepo.Save(ctx, pluginRecord); err != nil {
8✔
125
                h.responder.WriteError(ctx, rw, errors.WithMessage(err, "failed to update plugin record"))
1✔
126

1✔
127
                return
1✔
128
        }
1✔
129

130
        loaded, err := plugininstall.TryLoadPlugin(ctx, h.loader, h.pluginRepo, pluginRecord, filename)
6✔
131
        if err != nil {
7✔
132
                h.responder.WriteError(ctx, rw, api.WrapHTTPError(
1✔
133
                        errors.WithMessage(err, "plugin installed but failed to load"),
1✔
134
                        http.StatusUnprocessableEntity,
1✔
135
                ))
1✔
136

1✔
137
                return
1✔
138
        }
1✔
139

140
        h.recordDeclaredPermissions(ctx, pluginRecord, loaded)
5✔
141

5✔
142
        h.responder.Write(ctx, rw, newUpdateResponse(pluginRecord))
5✔
143
}
144

145
// recordDeclaredPermissions keeps required_permissions in step with the new
146
// build. Grants are not widened: a version that starts needing more is
147
// denied those calls until an operator grants them, which the warning and
148
// the admin UI point out.
149
func (h *Handler) recordDeclaredPermissions(
150
        ctx context.Context,
151
        pluginRecord *domain.Plugin,
152
        loaded *pkgplugin.LoadedPlugin,
153
) {
5✔
154
        if loaded == nil || loaded.Info == nil {
8✔
155
                return
3✔
156
        }
3✔
157

158
        declared := domain.ParsePluginPermissions(loaded.Info.RequiredPermissions)
2✔
159
        if !slices.Equal(declared, pluginRecord.RequiredPermissions) {
2✔
NEW
160
                pluginRecord.RequiredPermissions = declared
×
NEW
161

×
NEW
162
                if err := h.pluginRepo.Save(ctx, pluginRecord); err != nil {
×
NEW
163
                        slog.WarnContext(ctx, "failed to record the updated plugin's declared permissions",
×
NEW
164
                                slog.Uint64("plugin_id", uint64(pluginRecord.ID)),
×
NEW
165
                                slog.String("error", err.Error()))
×
NEW
166
                }
×
167
        }
168

169
        used := plugin.UsedPermissions(loaded.HostImports, loaded.SubscribedEvents)
2✔
170
        if missing := plugin.MissingPermissions(used, pluginRecord.AllowedPermissions); len(missing) > 0 {
2✔
NEW
171
                slog.WarnContext(ctx, "updated plugin uses permissions it is not granted; those calls will be refused",
×
NEW
172
                        slog.Uint64("plugin_id", uint64(pluginRecord.ID)),
×
NEW
173
                        slog.Any("missing_permissions", plugin.PermissionNames(missing)))
×
NEW
174
        }
×
175
}
176

177
func (h *Handler) parseInput(r *http.Request) (input, error) {
17✔
178
        var inp input
17✔
179
        if r.Body != nil && r.ContentLength > 0 {
19✔
180
                if err := json.NewDecoder(r.Body).Decode(&inp); err != nil {
3✔
181
                        return inp, api.WrapHTTPError(
1✔
182
                                errors.WithMessage(err, "invalid request body"),
1✔
183
                                http.StatusBadRequest,
1✔
184
                        )
1✔
185
                }
1✔
186
        }
187

188
        return inp, nil
16✔
189
}
190

191
func (h *Handler) findInstalledPlugin(ctx context.Context, dbID domain.Uint64ID) (*domain.Plugin, error) {
16✔
192
        installedPlugins, err := h.pluginRepo.Find(ctx, filters.FindPluginByIDs(dbID), nil, nil)
16✔
193
        if err != nil {
17✔
194
                return nil, errors.WithMessage(err, "failed to find installed plugin")
1✔
195
        }
1✔
196

197
        if len(installedPlugins) == 0 {
16✔
198
                return nil, api.WrapHTTPError(errors.New("plugin not installed"), http.StatusNotFound)
1✔
199
        }
1✔
200

201
        return &installedPlugins[0], nil
14✔
202
}
203

204
func (h *Handler) unloadPlugin(ctx context.Context, dbID domain.Uint64ID) error {
14✔
205
        if h.loader == nil {
24✔
206
                return nil
10✔
207
        }
10✔
208

209
        managerID, ok := h.loader.GetPluginManagerID(dbID)
4✔
210
        if !ok {
6✔
211
                managerID = pkgplugin.CompactPluginID(dbID)
2✔
212
        }
2✔
213

214
        if err := h.loader.Unload(ctx, managerID); err != nil {
6✔
215
                if errors.Is(err, pkgplugin.ErrPluginNotFound) {
3✔
216
                        return nil
1✔
217
                }
1✔
218

219
                return errors.WithMessage(err, "failed to unload plugin")
1✔
220
        }
221

222
        return nil
2✔
223
}
224

225
func (h *Handler) selectVersion(
226
        ctx context.Context,
227
        storePluginID string,
228
        requestedVersion string,
229
) (*pluginstore.PluginVersion, error) {
13✔
230
        versions, err := h.storeService.GetPluginVersions(ctx, storePluginID, pluginstore.GetPluginVersionsParams{
13✔
231
                Page:    1,
13✔
232
                PerPage: 100,
13✔
233
        })
13✔
234
        if err != nil {
14✔
235
                return nil, errors.WithMessage(err, "failed to get plugin versions")
1✔
236
        }
1✔
237

238
        if len(versions.Data) == 0 {
13✔
239
                return nil, api.WrapHTTPError(errors.New("no versions available for this plugin"), http.StatusNotFound)
1✔
240
        }
1✔
241

242
        return findVersion(versions.Data, requestedVersion)
11✔
243
}
244

245
func findVersion(versions []pluginstore.PluginVersion, requested string) (*pluginstore.PluginVersion, error) {
11✔
246
        if requested != "" {
12✔
247
                for i, v := range versions {
2✔
248
                        if v.Version == requested {
1✔
249
                                return &versions[i], nil
×
250
                        }
×
251
                }
252

253
                return nil, api.WrapHTTPError(errors.New("specified version not found"), http.StatusNotFound)
1✔
254
        }
255

256
        for i, v := range versions {
22✔
257
                if v.IsStable {
21✔
258
                        return &versions[i], nil
9✔
259
                }
9✔
260
        }
261

262
        return &versions[0], nil
1✔
263
}
264

265
func (h *Handler) downloadAndVerify(
266
        ctx context.Context,
267
        storePluginID string,
268
        version *pluginstore.PluginVersion,
269
) ([]byte, error) {
10✔
270
        wasmBytes, err := h.storeService.DownloadPlugin(ctx, storePluginID, version.Version)
10✔
271
        if err != nil {
11✔
272
                return nil, errors.WithMessage(err, "failed to download plugin")
1✔
273
        }
1✔
274

275
        if !pluginstore.VerifyHash(wasmBytes, version.FileHash) {
10✔
276
                return nil, api.WrapHTTPError(errors.New("plugin file hash mismatch"), http.StatusUnprocessableEntity)
1✔
277
        }
1✔
278

279
        return wasmBytes, nil
8✔
280
}
281

282
func (h *Handler) updatePluginRecord(record *domain.Plugin, version *pluginstore.PluginVersion, filename string) {
7✔
283
        record.Version = version.Version
7✔
284
        record.Filename = new(filename)
7✔
285
        record.Status = domain.PluginStatusActive
7✔
286
        record.UpdatedAt = new(time.Now())
7✔
287
}
7✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc