Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
projectdiscovery
GitHub Repository: projectdiscovery/nuclei
Path: blob/dev/pkg/installer/template.go
2851 views
1
package installer
2
3
import (
4
"bytes"
5
"context"
6
"crypto/md5"
7
"fmt"
8
"io"
9
"io/fs"
10
"os"
11
"path/filepath"
12
"strconv"
13
"strings"
14
15
"github.com/charmbracelet/glamour"
16
"github.com/olekukonko/tablewriter"
17
"github.com/projectdiscovery/gologger"
18
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
19
"github.com/projectdiscovery/nuclei/v3/pkg/external/customtemplates"
20
"github.com/projectdiscovery/utils/errkit"
21
fileutil "github.com/projectdiscovery/utils/file"
22
mapsutil "github.com/projectdiscovery/utils/maps"
23
stringsutil "github.com/projectdiscovery/utils/strings"
24
updateutils "github.com/projectdiscovery/utils/update"
25
)
26
27
const (
28
checkSumFilePerm = 0644
29
)
30
31
var (
32
HideProgressBar = true
33
HideUpdateChangesTable = false
34
HideReleaseNotes = true
35
)
36
37
// TemplateUpdateResults contains the results of template update
38
type templateUpdateResults struct {
39
additions []string
40
deletions []string
41
modifications []string
42
totalCount int
43
}
44
45
// String returns markdown table of template update results
46
func (t *templateUpdateResults) String() string {
47
var buff bytes.Buffer
48
data := [][]string{
49
{
50
strconv.Itoa(t.totalCount),
51
strconv.Itoa(len(t.additions)),
52
strconv.Itoa(len(t.modifications)),
53
strconv.Itoa(len(t.deletions)),
54
},
55
}
56
table := tablewriter.NewWriter(&buff)
57
table.Header([]string{"Total", "Added", "Modified", "Removed"})
58
for _, v := range data {
59
_ = table.Append(v)
60
}
61
_ = table.Render()
62
defer func() {
63
_ = table.Close()
64
}()
65
return buff.String()
66
}
67
68
// TemplateManager is a manager for templates.
69
// It downloads / updates / installs templates.
70
type TemplateManager struct {
71
CustomTemplates *customtemplates.CustomTemplatesManager // optional if given tries to download custom templates
72
DisablePublicTemplates bool // if true,
73
// public templates are not downloaded from the GitHub nuclei-templates repository
74
}
75
76
// FreshInstallIfNotExists installs templates if they are not already installed
77
// if templates directory already exists, it does nothing
78
func (t *TemplateManager) FreshInstallIfNotExists() error {
79
if fileutil.FolderExists(config.DefaultConfig.TemplatesDirectory) {
80
return nil
81
}
82
gologger.Info().Msgf("nuclei-templates are not installed, installing...")
83
if err := t.installTemplatesAt(config.DefaultConfig.TemplatesDirectory); err != nil {
84
return errkit.Wrapf(err, "failed to install templates at %s", config.DefaultConfig.TemplatesDirectory)
85
}
86
if t.CustomTemplates != nil {
87
t.CustomTemplates.Download(context.TODO())
88
}
89
return nil
90
}
91
92
// UpdateIfOutdated updates templates if they are outdated
93
func (t *TemplateManager) UpdateIfOutdated() error {
94
// if the templates folder does not exist, it's a fresh installation and do not update
95
if !fileutil.FolderExists(config.DefaultConfig.TemplatesDirectory) {
96
return t.FreshInstallIfNotExists()
97
}
98
99
needsUpdate := config.DefaultConfig.NeedsTemplateUpdate()
100
101
// NOTE(dwisiswant0): if PDTM API data is not available
102
// (LatestNucleiTemplatesVersion is empty) but we have a current template
103
// version, so we MUST verify against GitHub directly.
104
if !needsUpdate && config.DefaultConfig.LatestNucleiTemplatesVersion == "" && config.DefaultConfig.TemplateVersion != "" {
105
ghrd, err := updateutils.NewghReleaseDownloader(config.OfficialNucleiTemplatesRepoName)
106
if err == nil {
107
latestVersion := ghrd.Latest.GetTagName()
108
if config.IsOutdatedVersion(config.DefaultConfig.TemplateVersion, latestVersion) {
109
needsUpdate = true
110
gologger.Debug().Msgf("PDTM API unavailable, verified update needed via GitHub API: %s -> %s", config.DefaultConfig.TemplateVersion, latestVersion)
111
}
112
}
113
}
114
115
if needsUpdate {
116
return t.updateTemplatesAt(config.DefaultConfig.TemplatesDirectory)
117
}
118
return nil
119
}
120
121
// installTemplatesAt installs templates at given directory
122
func (t *TemplateManager) installTemplatesAt(dir string) error {
123
if !fileutil.FolderExists(dir) {
124
if err := fileutil.CreateFolder(dir); err != nil {
125
return errkit.Wrapf(err, "failed to create directory at %s", dir)
126
}
127
}
128
if t.DisablePublicTemplates {
129
gologger.Info().Msgf("Skipping installation of public nuclei-templates")
130
return nil
131
}
132
ghrd, err := updateutils.NewghReleaseDownloader(config.OfficialNucleiTemplatesRepoName)
133
if err != nil {
134
return errkit.Wrapf(err, "failed to install templates at %s", dir)
135
}
136
137
// write templates to disk
138
_, err = t.writeTemplatesToDisk(ghrd, dir)
139
if err != nil {
140
return errkit.Wrapf(err, "failed to write templates to disk at %s", dir)
141
}
142
gologger.Info().Msgf("Successfully installed nuclei-templates at %s", dir)
143
return nil
144
}
145
146
// updateTemplatesAt updates templates at given directory
147
func (t *TemplateManager) updateTemplatesAt(dir string) error {
148
if t.DisablePublicTemplates {
149
gologger.Info().Msgf("Skipping update of public nuclei-templates")
150
return nil
151
}
152
// firstly, read checksums from .checksum file these are used to generate stats
153
oldchecksums, err := t.getChecksumFromDir(dir)
154
if err != nil {
155
// if something went wrong, overwrite all files
156
oldchecksums = make(map[string]string)
157
}
158
159
ghrd, err := updateutils.NewghReleaseDownloader(config.OfficialNucleiTemplatesRepoName)
160
if err != nil {
161
return errkit.Wrapf(err, "failed to install templates at %s", dir)
162
}
163
164
latestVersion := ghrd.Latest.GetTagName()
165
currentVersion := config.DefaultConfig.TemplateVersion
166
167
if config.IsOutdatedVersion(currentVersion, latestVersion) {
168
gologger.Info().Msgf("Your current nuclei-templates %s are outdated. Latest is %s\n", currentVersion, latestVersion)
169
} else {
170
gologger.Debug().Msgf("Updating nuclei-templates from %s to %s (forced update)\n", currentVersion, latestVersion)
171
}
172
173
// write templates to disk
174
writtenPaths, err := t.writeTemplatesToDisk(ghrd, dir)
175
if err != nil {
176
return err
177
}
178
179
// cleanup orphaned templates that exist locally but weren't in the new release
180
if err := t.cleanupOrphanedTemplates(dir, writtenPaths); err != nil {
181
// log warning but don't fail the update
182
gologger.Warning().Msgf("failed to cleanup orphaned templates: %s", err)
183
} else {
184
// Regenerate metadata (index and checksum) after successful cleanup to ensure
185
// metadata accurately reflects the cleaned template tree. This prevents stale
186
// index entries and checksum entries for deleted templates.
187
if err := t.regenerateTemplateMetadata(dir); err != nil {
188
// Log warning but don't fail the update - metadata will be out of sync
189
// but templates are cleaned up correctly
190
gologger.Warning().Msgf("failed to regenerate template metadata after cleanup: %s", err)
191
}
192
}
193
194
// get checksums from new templates
195
newchecksums, err := t.getChecksumFromDir(dir)
196
if err != nil {
197
// unlikely this case will happen
198
return errkit.Wrapf(err, "failed to get checksums from %s after update", dir)
199
}
200
201
// summarize all changes
202
results := t.summarizeChanges(oldchecksums, newchecksums)
203
204
// remove deleted templates
205
for _, deletion := range results.deletions {
206
if err := os.Remove(deletion); err != nil && !os.IsNotExist(err) {
207
gologger.Warning().Msgf("failed to remove deleted template %s: %s", deletion, err)
208
}
209
}
210
211
// print summary
212
if results.totalCount > 0 {
213
gologger.Info().Msgf("Successfully updated nuclei-templates (%v) to %s. GoodLuck!", ghrd.Latest.GetTagName(), dir)
214
if !HideUpdateChangesTable {
215
// print summary table
216
gologger.Print().Msgf("\nNuclei Templates %s Changelog\n", ghrd.Latest.GetTagName())
217
gologger.Print().Msg(results.String())
218
}
219
} else {
220
gologger.Info().Msgf("Successfully updated nuclei-templates (%v) to %s. GoodLuck!", ghrd.Latest.GetTagName(), dir)
221
}
222
return nil
223
}
224
225
// summarizeChanges summarizes changes between old and new checksums
226
func (t *TemplateManager) summarizeChanges(old, new map[string]string) *templateUpdateResults {
227
results := &templateUpdateResults{}
228
for k, v := range new {
229
if oldv, ok := old[k]; ok {
230
if oldv != v {
231
results.modifications = append(results.modifications, k)
232
}
233
} else {
234
results.additions = append(results.additions, k)
235
}
236
}
237
for k := range old {
238
if _, ok := new[k]; !ok {
239
results.deletions = append(results.deletions, k)
240
}
241
}
242
results.totalCount = len(results.additions) + len(results.deletions) + len(results.modifications)
243
return results
244
}
245
246
// getAbsoluteFilePath returns an absolute path where a file should be written based on given uri(i.e., files in zip)
247
// if a returned path is empty, it means that file should not be written and skipped
248
func (t *TemplateManager) getAbsoluteFilePath(templateDir, uri string, f fs.FileInfo) string {
249
// overwrite .nuclei-ignore every time nuclei-templates are downloaded
250
if f.Name() == config.NucleiIgnoreFileName {
251
return config.DefaultConfig.GetIgnoreFilePath()
252
}
253
// skip all meta files
254
if !strings.EqualFold(f.Name(), config.NewTemplateAdditionsFileName) {
255
if strings.TrimSpace(f.Name()) == "" || strings.HasPrefix(f.Name(), ".") || strings.EqualFold(f.Name(), "README.md") {
256
return ""
257
}
258
}
259
260
// get root or leftmost directory name from path
261
// this is in format `projectdiscovery-nuclei-templates-commithash`
262
263
index := strings.Index(uri, "/")
264
if index == -1 {
265
// zip files does not have directory at all , in this case log error but continue
266
gologger.Warning().Msgf("failed to get directory name from uri: %s", uri)
267
return filepath.Join(templateDir, uri)
268
}
269
// separator is also included in rootDir
270
rootDirectory := uri[:index+1]
271
relPath := strings.TrimPrefix(uri, rootDirectory)
272
273
// if it is a github meta directory skip it
274
if stringsutil.HasPrefixAny(relPath, ".github", ".git") {
275
return ""
276
}
277
278
newPath := filepath.Clean(filepath.Join(templateDir, relPath))
279
280
if !strings.HasPrefix(newPath, templateDir) {
281
// we don't allow LFI
282
return ""
283
}
284
285
if newPath == templateDir || newPath == templateDir+string(os.PathSeparator) {
286
// skip writing the folder itself since it already exists
287
return ""
288
}
289
290
if relPath != "" && f.IsDir() {
291
// if uri is a directory, create it
292
if err := fileutil.CreateFolder(newPath); err != nil {
293
gologger.Warning().Msgf("uri %v: got %s while installing templates", uri, err)
294
}
295
return ""
296
}
297
return newPath
298
}
299
300
// writeTemplatesToDisk writes all templates to disk and returns a map of written file paths
301
// The returned map contains absolute paths of all template files that were successfully written
302
func (t *TemplateManager) writeTemplatesToDisk(ghrd *updateutils.GHReleaseDownloader, dir string) (*mapsutil.SyncLockMap[string, struct{}], error) {
303
localTemplatesIndex, err := config.GetNucleiTemplatesIndex()
304
if err != nil {
305
gologger.Warning().Msgf("failed to get local nuclei-templates index: %s", err)
306
if localTemplatesIndex == nil {
307
localTemplatesIndex = map[string]string{} // no-op
308
}
309
}
310
311
// Track all paths that are successfully written during this update
312
writtenPaths := mapsutil.NewSyncLockMap[string, struct{}]()
313
314
callbackFunc := func(uri string, f fs.FileInfo, r io.Reader) error {
315
writePath := t.getAbsoluteFilePath(dir, uri, f)
316
if writePath == "" {
317
// skip writing file
318
return nil
319
}
320
321
bin, err := io.ReadAll(r)
322
if err != nil {
323
// if error occurs, iteration also stops
324
return errkit.Wrapf(err, "failed to read file %s", uri)
325
}
326
// TODO: It might be better to just download index file from nuclei templates repo
327
// instead of creating it from scratch
328
id, _ := config.GetTemplateIDFromReader(bytes.NewReader(bin), uri)
329
if id != "" {
330
// based on template id, check if we are updating a path of official nuclei template
331
if oldPath, ok := localTemplatesIndex[id]; ok {
332
if oldPath != writePath {
333
// write new template at a new path and delete old template
334
if err := os.WriteFile(writePath, bin, f.Mode()); err != nil {
335
return errkit.Wrapf(err, "failed to write file %s", uri)
336
}
337
// Track the new path as written
338
_ = writtenPaths.Set(writePath, struct{}{})
339
// after successful write, remove old template
340
if err := os.Remove(oldPath); err != nil {
341
gologger.Warning().Msgf("failed to remove old template %s: %s", oldPath, err)
342
}
343
return nil
344
}
345
}
346
}
347
// no change in template Path of official templates
348
if err := os.WriteFile(writePath, bin, f.Mode()); err != nil {
349
return errkit.Wrapf(err, "failed to write file %s", uri)
350
}
351
// Track successfully written paths
352
_ = writtenPaths.Set(writePath, struct{}{})
353
return nil
354
}
355
err = ghrd.DownloadSourceWithCallback(!HideProgressBar, callbackFunc)
356
if err != nil {
357
return nil, errkit.Wrap(err, "failed to download templates")
358
}
359
360
if err := config.DefaultConfig.WriteTemplatesConfig(); err != nil {
361
return nil, errkit.Wrap(err, "failed to write templates config")
362
}
363
// update ignore hash after writing new templates
364
if err := config.DefaultConfig.UpdateNucleiIgnoreHash(); err != nil {
365
return nil, errkit.Wrap(err, "failed to update nuclei ignore hash")
366
}
367
368
// update templates version in config file
369
if err := config.DefaultConfig.SetTemplatesVersion(ghrd.Latest.GetTagName()); err != nil {
370
return nil, errkit.Wrap(err, "failed to update templates version")
371
}
372
373
PurgeEmptyDirectories(dir)
374
375
// generate index of all templates
376
_ = os.Remove(config.DefaultConfig.GetTemplateIndexFilePath())
377
378
index, err := config.GetNucleiTemplatesIndex()
379
if err != nil {
380
return nil, errkit.Wrap(err, "failed to get nuclei templates index")
381
}
382
383
if err = config.DefaultConfig.WriteTemplatesIndex(index); err != nil {
384
return nil, errkit.Wrap(err, "failed to write nuclei templates index")
385
}
386
387
if !HideReleaseNotes {
388
output := ghrd.Latest.GetBody()
389
// adjust colors for both dark / light terminal themes
390
r, err := glamour.NewTermRenderer(glamour.WithAutoStyle())
391
if err != nil {
392
gologger.Error().Msgf("markdown rendering not supported: %v", err)
393
}
394
if rendered, err := r.Render(output); err == nil {
395
output = rendered
396
} else {
397
gologger.Error().Msg(err.Error())
398
}
399
gologger.Print().Msgf("\n%v\n\n", output)
400
}
401
402
// after installation, create and write checksums to .checksum file
403
if err := t.writeChecksumFileInDir(dir); err != nil {
404
return nil, err
405
}
406
407
return writtenPaths, nil
408
}
409
410
// cleanupOrphanedTemplates removes template files that exist locally but were not part of the new release
411
// It scans the templates directory for template files and deletes those that are not in the writtenPaths set
412
// This function handles empty directories gracefully - if the directory is empty, no orphaned files will be found
413
func (t *TemplateManager) cleanupOrphanedTemplates(dir string, writtenPaths *mapsutil.SyncLockMap[string, struct{}]) error {
414
absDir, err := filepath.Abs(dir)
415
if err != nil {
416
return errkit.Wrapf(err, "failed to get absolute path of templates directory")
417
}
418
// Use Clean to normalize the path consistently (handles Windows paths better)
419
absDir = filepath.Clean(absDir)
420
421
// If directory doesn't exist, there's nothing to clean up
422
if !fileutil.FolderExists(absDir) {
423
return nil
424
}
425
426
// Normalize all written paths to absolute paths for comparison
427
normalizedWrittenPaths := mapsutil.NewSyncLockMap[string, struct{}]()
428
for path := range writtenPaths.GetAll() {
429
absPath, err := filepath.Abs(path)
430
if err == nil {
431
// Use Clean to normalize the path consistently (handles Windows paths better)
432
absPath = filepath.Clean(absPath)
433
_ = normalizedWrittenPaths.Set(absPath, struct{}{})
434
}
435
}
436
437
// Get custom template directories to exclude
438
customDirs := config.DefaultConfig.GetAllCustomTemplateDirs()
439
customDirAbs := make([]string, 0, len(customDirs))
440
for _, customDir := range customDirs {
441
if absCustomDir, err := filepath.Abs(customDir); err == nil {
442
// Use Clean to normalize the path consistently (handles Windows paths better)
443
absCustomDir = filepath.Clean(absCustomDir)
444
customDirAbs = append(customDirAbs, absCustomDir)
445
}
446
}
447
448
var orphanedFiles []string
449
450
// Walk the templates directory to find all template files
451
err = filepath.WalkDir(absDir, func(path string, d fs.DirEntry, err error) error {
452
if err != nil {
453
// Log but continue walking
454
gologger.Debug().Msgf("error accessing path %s during orphan cleanup: %s", path, err)
455
return nil
456
}
457
458
// Skip directories
459
if d.IsDir() {
460
return nil
461
}
462
463
absPath, err := filepath.Abs(path)
464
if err != nil {
465
return nil
466
}
467
// Use Clean to normalize the path consistently (handles Windows paths better)
468
absPath = filepath.Clean(absPath)
469
470
// Skip custom template directories
471
for _, customDir := range customDirAbs {
472
if strings.HasPrefix(absPath, customDir) {
473
return nil
474
}
475
}
476
477
// Only process template files
478
if !config.IsTemplate(absPath) {
479
return nil
480
}
481
482
// Skip if this file was written in the new release
483
if normalizedWrittenPaths.Has(absPath) {
484
return nil
485
}
486
487
// This is an orphaned template file
488
orphanedFiles = append(orphanedFiles, absPath)
489
return nil
490
})
491
492
if err != nil {
493
return errkit.Wrapf(err, "failed to walk templates directory for orphan cleanup")
494
}
495
496
// Delete orphaned files
497
for _, orphanPath := range orphanedFiles {
498
if err := os.Remove(orphanPath); err != nil {
499
if !os.IsNotExist(err) {
500
gologger.Warning().Msgf("failed to remove orphaned template %s: %s", orphanPath, err)
501
}
502
} else {
503
gologger.Debug().Msgf("removed orphaned template: %s", orphanPath)
504
}
505
}
506
507
if len(orphanedFiles) > 0 {
508
gologger.Info().Msgf("cleaned up %d orphaned template file(s)", len(orphanedFiles))
509
}
510
511
return nil
512
}
513
514
// regenerateTemplateMetadata regenerates template index and checksum files after cleanup operations.
515
// This ensures the metadata accurately reflects the current state of template files on disk.
516
func (t *TemplateManager) regenerateTemplateMetadata(dir string) error {
517
// Purge empty directories that may have been left after cleanup
518
PurgeEmptyDirectories(dir)
519
520
// Ensure templates directory exists (it may have been purged if empty)
521
if !fileutil.FolderExists(dir) {
522
if err := os.MkdirAll(dir, 0755); err != nil {
523
return errkit.Wrapf(err, "failed to recreate templates directory %s after purge", dir)
524
}
525
}
526
527
// Remove old index file and regenerate it from current templates on disk
528
indexFilePath := config.DefaultConfig.GetTemplateIndexFilePath()
529
if err := os.Remove(indexFilePath); err != nil && !os.IsNotExist(err) {
530
return errkit.Wrapf(err, "failed to remove old index file %s", indexFilePath)
531
}
532
533
// Force regeneration by ensuring the file doesn't exist (handles Windows file handle issues)
534
// GetNucleiTemplatesIndex will scan the directory if the file doesn't exist
535
index, err := config.GetNucleiTemplatesIndex()
536
if err != nil {
537
return errkit.Wrap(err, "failed to regenerate nuclei templates index after cleanup")
538
}
539
540
// Filter out any entries that don't actually exist on disk (Windows file deletion timing issues)
541
filteredIndex := make(map[string]string)
542
for id, path := range index {
543
if fileutil.FileExists(path) {
544
filteredIndex[id] = path
545
}
546
}
547
548
if err = config.DefaultConfig.WriteTemplatesIndex(filteredIndex); err != nil {
549
return errkit.Wrap(err, "failed to write nuclei templates index after cleanup")
550
}
551
552
// Regenerate checksum file to reflect current templates on disk
553
if err := t.writeChecksumFileInDir(dir); err != nil {
554
return errkit.Wrap(err, "failed to regenerate checksum file after cleanup")
555
}
556
557
return nil
558
}
559
560
// getChecksumFromDir returns a map containing checksums (md5 hash) of all yaml files (with .yaml extension)
561
// if .checksum file does not exist, checksums are calculated and returned
562
func (t *TemplateManager) getChecksumFromDir(dir string) (map[string]string, error) {
563
checksumFilePath := config.DefaultConfig.GetChecksumFilePath()
564
if fileutil.FileExists(checksumFilePath) {
565
checksums, err := os.ReadFile(checksumFilePath)
566
if err == nil {
567
allChecksums := make(map[string]string)
568
for _, v := range strings.Split(string(checksums), ";") {
569
v = strings.TrimSpace(v)
570
tmparr := strings.Split(v, ",")
571
if len(tmparr) != 2 {
572
continue
573
}
574
allChecksums[tmparr[0]] = tmparr[1]
575
}
576
return allChecksums, nil
577
}
578
}
579
return t.calculateChecksumMap(dir)
580
}
581
582
// writeChecksumFileInDir creates checksums of all yaml files in given directory
583
// and writes them to a file named .checksum
584
func (t *TemplateManager) writeChecksumFileInDir(dir string) error {
585
checksumMap, err := t.calculateChecksumMap(dir)
586
if err != nil {
587
return err
588
}
589
var buff bytes.Buffer
590
for k, v := range checksumMap {
591
buff.WriteString(k)
592
buff.WriteString(",")
593
buff.WriteString(v)
594
buff.WriteString(";")
595
}
596
return os.WriteFile(config.DefaultConfig.GetChecksumFilePath(), buff.Bytes(), checkSumFilePerm)
597
}
598
599
// getChecksumMap returns a map containing checksums (md5 hash) of all yaml files (with .yaml extension)
600
func (t *TemplateManager) calculateChecksumMap(dir string) (map[string]string, error) {
601
// getchecksumMap walks given directory `dir` and returns a map containing
602
// checksums (md5 hash) of all yaml files (with .yaml extension) and the
603
// format is map[filePath]checksum
604
checksumMap := map[string]string{}
605
606
getChecksum := func(filepath string) (string, error) {
607
// return md5 hash of the file
608
bin, err := os.ReadFile(filepath)
609
if err != nil {
610
return "", err
611
}
612
return fmt.Sprintf("%x", md5.Sum(bin)), nil
613
}
614
615
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
616
if err != nil {
617
return err
618
}
619
// skip checksums of custom templates i.e github and s3
620
if stringsutil.HasPrefixAny(path, config.DefaultConfig.GetAllCustomTemplateDirs()...) {
621
return nil
622
}
623
624
// current implementations calculates checksums of all files (including .yaml,.txt,.md,.json etc)
625
if !d.IsDir() {
626
checksum, err := getChecksum(path)
627
if err != nil {
628
return err
629
}
630
checksumMap[path] = checksum
631
}
632
return nil
633
})
634
return checksumMap, errkit.Wrap(err, "failed to calculate checksums of templates")
635
}
636
637